mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 04:02:41 +09:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed10f9d323 | ||
|
|
4b278a0da8 | ||
|
|
1281045648 | ||
|
|
69c42cff90 | ||
|
|
7d56481aba |
@@ -10563,6 +10563,9 @@
|
||||
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
|
||||
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
|
||||
"message_hover_tracking": {"$ref": "#/components/schemas/MessageHoverTrackingConfigResponse"},
|
||||
"message_keyboard_focus": {"$ref": "#/components/schemas/MessageKeyboardFocusConfigResponse"},
|
||||
"blocked_message_groups": {"$ref": "#/components/schemas/BlockedMessageGroupsConfigResponse"},
|
||||
"registration": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -10966,6 +10969,9 @@
|
||||
"gateway_rollout",
|
||||
"voice_noise_suppression",
|
||||
"experiment_delivery",
|
||||
"message_hover_tracking",
|
||||
"message_keyboard_focus",
|
||||
"blocked_message_groups",
|
||||
"registration",
|
||||
"self_hosted",
|
||||
"app_public",
|
||||
@@ -11103,6 +11109,18 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
|
||||
},
|
||||
"message_hover_tracking": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/MessageHoverTrackingConfigUpdateRequest"}]
|
||||
},
|
||||
"message_keyboard_focus": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/MessageKeyboardFocusConfigUpdateRequest"}]
|
||||
},
|
||||
"blocked_message_groups": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/BlockedMessageGroupsConfigUpdateRequest"}]
|
||||
},
|
||||
"registration": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
@@ -15159,6 +15177,60 @@
|
||||
"enum": ["open", "approval", "closed"],
|
||||
"type": "string"
|
||||
},
|
||||
"BlockedMessageGroupsConfigUpdateRequest": {
|
||||
"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}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"MessageKeyboardFocusConfigUpdateRequest": {
|
||||
"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}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"MessageHoverTrackingConfigUpdateRequest": {
|
||||
"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}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ExperimentDeliveryConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15223,6 +15295,96 @@
|
||||
"type": "string",
|
||||
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"]
|
||||
},
|
||||
"BlockedMessageGroupsConfigResponse": {
|
||||
"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": "blocked-message-groups-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
|
||||
},
|
||||
"MessageKeyboardFocusConfigResponse": {
|
||||
"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": "message-keyboard-focus-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
|
||||
},
|
||||
"MessageHoverTrackingConfigResponse": {
|
||||
"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": "message-hover-tracking-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
|
||||
},
|
||||
"ExperimentDeliveryConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -24,6 +24,12 @@ pub struct InstanceConfigResponse {
|
||||
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
#[serde(default)]
|
||||
pub message_hover_tracking: MessageHoverTrackingConfigResponse,
|
||||
#[serde(default)]
|
||||
pub message_keyboard_focus: MessageKeyboardFocusConfigResponse,
|
||||
#[serde(default)]
|
||||
pub blocked_message_groups: BlockedMessageGroupsConfigResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -537,6 +543,120 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
pub suppression_strength: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct MessageHoverTrackingConfigResponse {
|
||||
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 MessageHoverTrackingConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "message-hover-tracking-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct MessageHoverTrackingConfigUpdateRequest {
|
||||
#[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 MessageKeyboardFocusConfigResponse {
|
||||
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 MessageKeyboardFocusConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "message-keyboard-focus-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct MessageKeyboardFocusConfigUpdateRequest {
|
||||
#[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 BlockedMessageGroupsConfigResponse {
|
||||
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 BlockedMessageGroupsConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: "blocked-message-groups-v1".to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct BlockedMessageGroupsConfigUpdateRequest {
|
||||
#[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 {
|
||||
@@ -654,6 +774,12 @@ pub struct InstanceConfigUpdateRequest {
|
||||
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message_hover_tracking: Option<MessageHoverTrackingConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message_keyboard_focus: Option<MessageKeyboardFocusConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blocked_message_groups: Option<BlockedMessageGroupsConfigUpdateRequest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -988,12 +1114,33 @@ mod tests {
|
||||
.expect("default noise config");
|
||||
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
|
||||
.expect("default delivery config");
|
||||
let hover = serde_json::from_value::<MessageHoverTrackingConfigResponse>(json!({}))
|
||||
.expect("default message hover tracking config");
|
||||
let keyboard = serde_json::from_value::<MessageKeyboardFocusConfigResponse>(json!({}))
|
||||
.expect("default message keyboard focus config");
|
||||
let blocked = serde_json::from_value::<BlockedMessageGroupsConfigResponse>(json!({}))
|
||||
.expect("default blocked message groups 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 =
|
||||
serde_json::to_value(hover).expect("serializable message hover tracking config");
|
||||
let keyboard =
|
||||
serde_json::to_value(keyboard).expect("serializable message keyboard focus config");
|
||||
let blocked =
|
||||
serde_json::to_value(blocked).expect("serializable blocked message groups config");
|
||||
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
|
||||
serde_json::from_value(noise.clone()).expect("generated noise config contract");
|
||||
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
|
||||
let generated_hover: generated_types::MessageHoverTrackingConfigResponse =
|
||||
serde_json::from_value(hover.clone())
|
||||
.expect("generated message hover tracking config contract");
|
||||
let generated_keyboard: generated_types::MessageKeyboardFocusConfigResponse =
|
||||
serde_json::from_value(keyboard.clone())
|
||||
.expect("generated message keyboard focus config contract");
|
||||
let generated_blocked: generated_types::BlockedMessageGroupsConfigResponse =
|
||||
serde_json::from_value(blocked.clone())
|
||||
.expect("generated blocked message groups config contract");
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
|
||||
noise
|
||||
@@ -1003,9 +1150,27 @@ mod tests {
|
||||
.expect("serializable generated delivery config"),
|
||||
delivery
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_hover)
|
||||
.expect("serializable generated message hover tracking config"),
|
||||
hover
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_keyboard)
|
||||
.expect("serializable generated message keyboard focus config"),
|
||||
keyboard
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_blocked)
|
||||
.expect("serializable generated blocked message groups config"),
|
||||
blocked
|
||||
);
|
||||
for (name, value) in [
|
||||
("VoiceNoiseSuppressionConfigResponse", noise),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
("MessageHoverTrackingConfigResponse", hover),
|
||||
("MessageKeyboardFocusConfigResponse", keyboard),
|
||||
("BlockedMessageGroupsConfigResponse", blocked),
|
||||
] {
|
||||
for (field, value) in value.as_object().expect("config object") {
|
||||
assert_eq!(
|
||||
@@ -1016,6 +1181,75 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_hover_tracking_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = MessageHoverTrackingConfigUpdateRequest {
|
||||
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::MessageHoverTrackingConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(MessageHoverTrackingConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_keyboard_focus_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = MessageKeyboardFocusConfigUpdateRequest {
|
||||
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::MessageKeyboardFocusConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(MessageKeyboardFocusConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_message_groups_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = BlockedMessageGroupsConfigUpdateRequest {
|
||||
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::BlockedMessageGroupsConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(BlockedMessageGroupsConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
|
||||
@@ -6,21 +6,22 @@ use crate::{
|
||||
types::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DeferredPhoneGateUpdateRequest, ExperimentDeliveryConfigUpdateRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
|
||||
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
|
||||
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
|
||||
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
|
||||
VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
|
||||
VoiceNoiseSuppressionGuildOverride,
|
||||
AppSetupConfigUpdateRequest, BlockedMessageGroupsConfigUpdateRequest,
|
||||
CreateRegistrationUrlRequest, DeferredPhoneGateUpdateRequest,
|
||||
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
|
||||
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
|
||||
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
|
||||
InstanceCaptchaIntegrationUpdateRequest, InstanceConfigUpdateRequest,
|
||||
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters,
|
||||
MessageHoverTrackingConfigUpdateRequest, MessageKeyboardFocusConfigUpdateRequest,
|
||||
NoiseSuppressionBackend, PremiumMode, RegistrationMode, SsoConfigUpdateRequest,
|
||||
VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS, VoiceE2eeScope,
|
||||
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -207,6 +208,18 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_message_hover_tracking" => match build_message_hover_tracking_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_message_keyboard_focus" => match build_message_keyboard_focus_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_blocked_message_groups" => match build_blocked_message_groups_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),
|
||||
@@ -447,10 +460,10 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe
|
||||
}
|
||||
}
|
||||
|
||||
const VOICE_NS_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
|
||||
const EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
|
||||
const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100;
|
||||
const VOICE_NS_MAX_ROLLOUT_SALT_CHARS: usize = 64;
|
||||
const VOICE_NS_MAX_SNOWFLAKE_LENGTH: usize = 20;
|
||||
const EXPERIMENT_MAX_ROLLOUT_SALT_CHARS: usize = 64;
|
||||
const EXPERIMENT_MAX_SNOWFLAKE_LENGTH: usize = 20;
|
||||
const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
|
||||
const EXPERIMENT_MAX_POLL_INTERVAL_SECONDS: u64 = 86_400;
|
||||
const EXPERIMENT_MAX_POLL_JITTER_PERCENT: u32 = 50;
|
||||
@@ -476,35 +489,36 @@ where
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_rollout_salt(
|
||||
fn parse_experiment_rollout_salt(
|
||||
form: &MultiValueForm,
|
||||
key: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(raw) = form.first("voice_ns_rollout_salt") else {
|
||||
let Some(raw) = form.first(key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let salt = raw.trim();
|
||||
if salt.is_empty() || salt.encode_utf16().count() > VOICE_NS_MAX_ROLLOUT_SALT_CHARS {
|
||||
if salt.is_empty() || salt.encode_utf16().count() > EXPERIMENT_MAX_ROLLOUT_SALT_CHARS {
|
||||
return Err(format!(
|
||||
"Rollout salt must be between 1 and {VOICE_NS_MAX_ROLLOUT_SALT_CHARS} characters"
|
||||
"Rollout salt must be between 1 and {EXPERIMENT_MAX_ROLLOUT_SALT_CHARS} characters"
|
||||
));
|
||||
}
|
||||
Ok(Some(salt.to_owned()))
|
||||
}
|
||||
|
||||
fn is_voice_noise_suppression_snowflake(value: &str) -> bool {
|
||||
fn is_experiment_snowflake(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= VOICE_NS_MAX_SNOWFLAKE_LENGTH
|
||||
&& value.len() <= EXPERIMENT_MAX_SNOWFLAKE_LENGTH
|
||||
&& value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
|
||||
fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
for (index, candidate) in value.split([',', '\n', '\r']).enumerate() {
|
||||
let candidate = candidate.trim();
|
||||
if candidate.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !is_voice_noise_suppression_snowflake(candidate) {
|
||||
if !is_experiment_snowflake(candidate) {
|
||||
return Err(format!(
|
||||
"{label} entry {} must contain 1 to 20 decimal digits",
|
||||
index + 1
|
||||
@@ -536,7 +550,7 @@ fn parse_voice_noise_suppression_guild_overrides(
|
||||
format!("Guild overrides line {line_number} must use guild_id=backend")
|
||||
})?;
|
||||
let guild_id = guild_id.trim();
|
||||
if !is_voice_noise_suppression_snowflake(guild_id) {
|
||||
if !is_experiment_snowflake(guild_id) {
|
||||
return Err(format!(
|
||||
"Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits"
|
||||
));
|
||||
@@ -602,14 +616,14 @@ fn build_voice_noise_suppression_update(
|
||||
"voice_ns_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
VOICE_NS_ROLLOUT_BASIS_POINTS_MAX,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_voice_noise_suppression_rollout_salt(form)?,
|
||||
included_user_ids: Some(parse_voice_noise_suppression_user_ids(
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "voice_ns_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_ns_included_user_ids").unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_voice_noise_suppression_user_ids(
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
@@ -629,6 +643,96 @@ fn build_voice_noise_suppression_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_message_hover_tracking_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
message_hover_tracking: Some(MessageHoverTrackingConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("message_hover_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"message_hover_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "message_hover_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("message_hover_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("message_hover_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_message_keyboard_focus_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
message_keyboard_focus: Some(MessageKeyboardFocusConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("message_keyboard_focus_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"message_keyboard_focus_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(
|
||||
form,
|
||||
"message_keyboard_focus_rollout_salt",
|
||||
)?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("message_keyboard_focus_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("message_keyboard_focus_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_blocked_message_groups_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
blocked_message_groups: Some(BlockedMessageGroupsConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("blocked_groups_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"blocked_groups_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "blocked_groups_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("blocked_groups_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("blocked_groups_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
@@ -1305,9 +1409,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_splits_newlines_and_commas() {
|
||||
fn parse_experiment_user_ids_splits_newlines_and_commas() {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
|
||||
parse_experiment_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
|
||||
.expect("valid IDs"),
|
||||
vec![
|
||||
"1".to_owned(),
|
||||
@@ -1320,16 +1424,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_dedupes_preserving_order() {
|
||||
fn parse_experiment_user_ids_dedupes_preserving_order() {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids("20,10,20,10,30", "Included user IDs")
|
||||
.expect("valid IDs"),
|
||||
parse_experiment_user_ids("20,10,20,10,30", "Included user IDs").expect("valid IDs"),
|
||||
vec!["20".to_owned(), "10".to_owned(), "30".to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_rejects_non_digit_and_overlong_values() {
|
||||
fn parse_experiment_user_ids_rejects_non_digit_and_overlong_values() {
|
||||
for value in [
|
||||
"abc",
|
||||
"12a",
|
||||
@@ -1339,11 +1442,8 @@ mod tests {
|
||||
"<script>",
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(
|
||||
&format!("123,{value}"),
|
||||
"Included user IDs"
|
||||
)
|
||||
.expect_err("invalid ID"),
|
||||
parse_experiment_user_ids(&format!("123,{value}"), "Included user IDs")
|
||||
.expect_err("invalid ID"),
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
"{value}"
|
||||
);
|
||||
@@ -1351,18 +1451,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_rejects_exceeding_the_cap() {
|
||||
fn parse_experiment_user_ids_rejects_exceeding_the_cap() {
|
||||
let value = (0..VOICE_NS_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let ids =
|
||||
parse_voice_noise_suppression_user_ids(&format!("{value}\n999"), "Included user IDs")
|
||||
.expect("valid IDs at cap");
|
||||
let ids = parse_experiment_user_ids(&format!("{value}\n999"), "Included user IDs")
|
||||
.expect("valid IDs at cap");
|
||||
assert_eq!(ids.len(), VOICE_NS_MAX_TARGETED_USERS);
|
||||
assert_eq!(ids.last(), Some(&"999".to_owned()));
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(&format!("{value}\n1000"), "Included user IDs")
|
||||
parse_experiment_user_ids(&format!("{value}\n1000"), "Included user IDs")
|
||||
.expect_err("too many IDs"),
|
||||
"Included user IDs must contain at most 1000 unique IDs"
|
||||
);
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
use crate::{
|
||||
api::types::{
|
||||
AppPublicConfigResponse, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse,
|
||||
InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse,
|
||||
InstancePolicyResponse, InstanceRegistrationResponse, LimitConfigResponse,
|
||||
NoiseSuppressionBackend, PendingRegistrationResponse, RegistrationUrlResponse,
|
||||
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
|
||||
VoiceNoiseSuppressionConfigResponse,
|
||||
AppPublicConfigResponse, BlockedMessageGroupsConfigResponse,
|
||||
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, 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,
|
||||
@@ -148,6 +149,9 @@ pub fn instance_config_page(
|
||||
html! {
|
||||
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
||||
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
|
||||
(message_hover_tracking_section(base, csrf_token, &instance_config.message_hover_tracking))
|
||||
(message_keyboard_focus_section(base, csrf_token, &instance_config.message_keyboard_focus))
|
||||
(blocked_message_groups_section(base, csrf_token, &instance_config.blocked_message_groups))
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
@@ -1175,6 +1179,318 @@ fn voice_noise_suppression_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn message_hover_tracking_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
message_hover_tracking: &MessageHoverTrackingConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if message_hover_tracking.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = message_hover_tracking.included_user_ids.join("\n");
|
||||
let excluded_user_ids = message_hover_tracking.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Message Hover Tracking",
|
||||
"Picks which message hover implementation targeted clients run in the message list. A \
|
||||
targeted client resolves the hovered message from one shared pointer oracle and drives \
|
||||
the message action bar from that state. While the master switch below is off every \
|
||||
client keeps the per-row implementation it ships with, whatever the rest of these \
|
||||
fields say.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_message_hover_tracking"} {
|
||||
(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 " (message_hover_tracking.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"message_hover_enabled",
|
||||
"true",
|
||||
"Serve message hover tracking assignments to clients",
|
||||
message_hover_tracking.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 hover behavior, so the rollout \
|
||||
and targeting fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"message_hover_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&message_hover_tracking.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(
|
||||
"message_hover_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&message_hover_tracking.rollout_salt,
|
||||
"message-hover-tracking-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(
|
||||
"message_hover_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(
|
||||
"message_hover_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 Message Hover Tracking Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn message_keyboard_focus_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
message_keyboard_focus: &MessageKeyboardFocusConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if message_keyboard_focus.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = message_keyboard_focus.included_user_ids.join("\n");
|
||||
let excluded_user_ids = message_keyboard_focus.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Message Keyboard Focus",
|
||||
"Picks whether targeted clients run the keyboard navigation rework in the message list. \
|
||||
A targeted client reaches the message list from the composer with one Tab, walks \
|
||||
messages with the arrow keys through revealed blocked groups, and draws the focus ring \
|
||||
inside each row. While the master switch below is off every client keeps the keyboard \
|
||||
navigation it ships with, whatever the rest of these fields say.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_message_keyboard_focus"} {
|
||||
(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 " (message_keyboard_focus.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"message_keyboard_focus_enabled",
|
||||
"true",
|
||||
"Serve message keyboard focus assignments to clients",
|
||||
message_keyboard_focus.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 keyboard navigation, so the rollout \
|
||||
and targeting fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"message_keyboard_focus_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&message_keyboard_focus.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(
|
||||
"message_keyboard_focus_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&message_keyboard_focus.rollout_salt,
|
||||
"message-keyboard-focus-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(
|
||||
"message_keyboard_focus_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(
|
||||
"message_keyboard_focus_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 Message Keyboard Focus Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn blocked_message_groups_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
blocked_message_groups: &BlockedMessageGroupsConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if blocked_message_groups.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let included_user_ids = blocked_message_groups.included_user_ids.join("\n");
|
||||
let excluded_user_ids = blocked_message_groups.excluded_user_ids.join("\n");
|
||||
section_card_with_description(
|
||||
"Blocked Message Groups",
|
||||
"Picks how targeted clients render a revealed block of blocked or suspected spam \
|
||||
messages. A targeted client draws the block full width, spaces consecutive message \
|
||||
groups inside it, and keys an unread divider apart from the group below it. While the \
|
||||
master switch below is off every client keeps the rendering it ships with, whatever the \
|
||||
rest of these fields say.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_blocked_message_groups"} {
|
||||
(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 " (blocked_message_groups.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"blocked_groups_enabled",
|
||||
"true",
|
||||
"Serve blocked message groups assignments to clients",
|
||||
blocked_message_groups.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 rendering, so the rollout \
|
||||
and targeting fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"blocked_groups_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&blocked_message_groups.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(
|
||||
"blocked_groups_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&blocked_message_groups.rollout_salt,
|
||||
"blocked-message-groups-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(
|
||||
"blocked_groups_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(
|
||||
"blocked_groups_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 Blocked Message Groups Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn experiment_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
|
||||
@@ -31,7 +31,10 @@ import {
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {BlockedMessageGroupsConfigSchema} from '@fluxer/schema/src/domains/experiment/BlockedMessageGroupsSchemas';
|
||||
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
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';
|
||||
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
|
||||
import type {Context} from 'hono';
|
||||
@@ -58,6 +61,9 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
gatewayRollout,
|
||||
voiceNoiseSuppression,
|
||||
experimentDelivery,
|
||||
messageHoverTracking,
|
||||
messageKeyboardFocus,
|
||||
blockedMessageGroups,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
pendingRegistrations,
|
||||
@@ -66,6 +72,9 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getMessageHoverTrackingConfig(),
|
||||
instanceConfigRepository.getMessageKeyboardFocusConfig(),
|
||||
instanceConfigRepository.getBlockedMessageGroupsConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
instanceConfigRepository.getPendingRegistrations(),
|
||||
@@ -97,6 +106,9 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
gateway_rollout: gatewayRollout,
|
||||
voice_noise_suppression: voiceNoiseSuppression,
|
||||
experiment_delivery: experimentDelivery,
|
||||
message_hover_tracking: messageHoverTracking,
|
||||
message_keyboard_focus: messageKeyboardFocus,
|
||||
blocked_message_groups: blockedMessageGroups,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
urls: registrationUrls,
|
||||
@@ -240,6 +252,42 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
await instanceConfigRepository.setVoiceNoiseSuppressionConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.message_hover_tracking) {
|
||||
const patch = omitUndefinedFields(data.message_hover_tracking);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const currentMessageHoverTracking = await instanceConfigRepository.getMessageHoverTrackingConfig();
|
||||
const validated = MessageHoverTrackingConfigSchema.parse({
|
||||
...currentMessageHoverTracking,
|
||||
...patch,
|
||||
config_version: currentMessageHoverTracking.config_version + 1,
|
||||
});
|
||||
await instanceConfigRepository.setMessageHoverTrackingConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.message_keyboard_focus) {
|
||||
const patch = omitUndefinedFields(data.message_keyboard_focus);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const currentMessageKeyboardFocus = await instanceConfigRepository.getMessageKeyboardFocusConfig();
|
||||
const validated = MessageKeyboardFocusConfigSchema.parse({
|
||||
...currentMessageKeyboardFocus,
|
||||
...patch,
|
||||
config_version: currentMessageKeyboardFocus.config_version + 1,
|
||||
});
|
||||
await instanceConfigRepository.setMessageKeyboardFocusConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.blocked_message_groups) {
|
||||
const patch = omitUndefinedFields(data.blocked_message_groups);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const currentBlockedMessageGroups = await instanceConfigRepository.getBlockedMessageGroupsConfig();
|
||||
const validated = BlockedMessageGroupsConfigSchema.parse({
|
||||
...currentBlockedMessageGroups,
|
||||
...patch,
|
||||
config_version: currentBlockedMessageGroups.config_version + 1,
|
||||
});
|
||||
await instanceConfigRepository.setBlockedMessageGroupsConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
const currentExperimentDelivery = await instanceConfigRepository.getExperimentDeliveryConfig();
|
||||
const validated = ExperimentDeliveryConfigSchema.parse({
|
||||
|
||||
@@ -9,7 +9,10 @@ import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {entityTagMatches} from '@app/api/utils/EntityTag';
|
||||
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
|
||||
import {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {resolveBlockedMessageGroupsAssignment} from '@fluxer/schema/src/domains/experiment/BlockedMessageGroupsSchemas';
|
||||
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {resolveMessageHoverTrackingAssignment} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas';
|
||||
import {resolveMessageKeyboardFocusAssignment} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas';
|
||||
|
||||
export function ExperimentController(app: HonoApp) {
|
||||
app.get(
|
||||
@@ -28,15 +31,28 @@ export function ExperimentController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [delivery, voiceConfig] = await Promise.all([
|
||||
const [
|
||||
delivery,
|
||||
voiceConfig,
|
||||
messageHoverTrackingConfig,
|
||||
messageKeyboardFocusConfig,
|
||||
blockedMessageGroupsConfig,
|
||||
] = await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getMessageHoverTrackingConfig(),
|
||||
instanceConfigRepository.getMessageKeyboardFocusConfig(),
|
||||
instanceConfigRepository.getBlockedMessageGroupsConfig(),
|
||||
]);
|
||||
const userId = ctx.get('user').id.toString();
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: delivery.poll_interval_seconds,
|
||||
poll_jitter_percent: delivery.poll_jitter_percent,
|
||||
assignments: {
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, ctx.get('user').id.toString()),
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
|
||||
message_hover_tracking: resolveMessageHoverTrackingAssignment(messageHoverTrackingConfig, userId),
|
||||
message_keyboard_focus: resolveMessageKeyboardFocusAssignment(messageKeyboardFocusConfig, userId),
|
||||
blocked_message_groups: resolveBlockedMessageGroupsAssignment(blockedMessageGroupsConfig, userId),
|
||||
},
|
||||
};
|
||||
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
|
||||
|
||||
@@ -10,13 +10,28 @@ import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {
|
||||
DEFAULT_BLOCKED_MESSAGE_GROUPS_CONFIG,
|
||||
INERT_BLOCKED_MESSAGE_GROUPS_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/BlockedMessageGroupsSchemas';
|
||||
import {
|
||||
DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
|
||||
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
type ExperimentAssignmentsResponse,
|
||||
type ExperimentDeliveryConfigResponse,
|
||||
readBlockedMessageGroupsAssignment,
|
||||
readMessageHoverTrackingAssignment,
|
||||
readMessageKeyboardFocusAssignment,
|
||||
readVoiceNoiseSuppressionAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {
|
||||
DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG,
|
||||
INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas';
|
||||
import {
|
||||
DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG,
|
||||
INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const NOT_MODIFIED = 304;
|
||||
@@ -49,7 +64,12 @@ describe('GET /experiments', () => {
|
||||
expect(body).toEqual({
|
||||
poll_interval_seconds: DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
|
||||
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
assignments: {voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT},
|
||||
assignments: {
|
||||
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
message_hover_tracking: INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT,
|
||||
message_keyboard_focus: INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT,
|
||||
blocked_message_groups: INERT_BLOCKED_MESSAGE_GROUPS_ASSIGNMENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +100,170 @@ describe('GET /experiments', () => {
|
||||
expect(readVoiceNoiseSuppressionAssignment(body).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('populates the message hover tracking 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, 'message_hover_tracking')).toBe(true);
|
||||
expect(readMessageHoverTrackingAssignment(body)).toEqual(INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT);
|
||||
});
|
||||
|
||||
it('targets an allowlisted account for message hover tracking', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setMessageHoverTrackingConfig({
|
||||
...DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
included_user_ids: [account.userId],
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readMessageHoverTrackingAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
user_targeted: true,
|
||||
source: 'user_rule',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves an account outside a zero-width message hover tracking rollout', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setMessageHoverTrackingConfig({
|
||||
...DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readMessageHoverTrackingAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
user_targeted: false,
|
||||
source: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('populates the message keyboard focus 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, 'message_keyboard_focus')).toBe(true);
|
||||
expect(readMessageKeyboardFocusAssignment(body)).toEqual(INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT);
|
||||
});
|
||||
|
||||
it('targets an allowlisted account for message keyboard focus', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setMessageKeyboardFocusConfig({
|
||||
...DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
included_user_ids: [account.userId],
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readMessageKeyboardFocusAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
user_targeted: true,
|
||||
source: 'user_rule',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves an account outside a zero-width message keyboard focus rollout', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setMessageKeyboardFocusConfig({
|
||||
...DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readMessageKeyboardFocusAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
user_targeted: false,
|
||||
source: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('populates the blocked message groups 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, 'blocked_message_groups')).toBe(true);
|
||||
expect(readBlockedMessageGroupsAssignment(body)).toEqual(INERT_BLOCKED_MESSAGE_GROUPS_ASSIGNMENT);
|
||||
});
|
||||
|
||||
it('targets an allowlisted account for blocked message groups', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setBlockedMessageGroupsConfig({
|
||||
...DEFAULT_BLOCKED_MESSAGE_GROUPS_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
included_user_ids: [account.userId],
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readBlockedMessageGroupsAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 4,
|
||||
user_targeted: true,
|
||||
source: 'user_rule',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves an account outside a zero-width blocked message groups rollout', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setBlockedMessageGroupsConfig({
|
||||
...DEFAULT_BLOCKED_MESSAGE_GROUPS_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readBlockedMessageGroupsAssignment(body)).toEqual({
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
user_targeted: false,
|
||||
source: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves all four experiments independently', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setMessageHoverTrackingConfig({
|
||||
...DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
await getInstanceConfigRepository().setMessageKeyboardFocusConfig({
|
||||
...DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
await getInstanceConfigRepository().setBlockedMessageGroupsConfig({
|
||||
...DEFAULT_BLOCKED_MESSAGE_GROUPS_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readMessageHoverTrackingAssignment(body).user_targeted).toBe(true);
|
||||
expect(readMessageKeyboardFocusAssignment(body).user_targeted).toBe(true);
|
||||
expect(readBlockedMessageGroupsAssignment(body).user_targeted).toBe(true);
|
||||
expect(readVoiceNoiseSuppressionAssignment(body).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('serves the delivery cadence from the delivery config and not from the voice config', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setExperimentDeliveryConfig({
|
||||
|
||||
@@ -13,13 +13,28 @@ import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
type VoiceNoiseSuppressionConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {
|
||||
type BlockedMessageGroupsConfig,
|
||||
DEFAULT_BLOCKED_MESSAGE_GROUPS_CONFIG,
|
||||
} from '@fluxer/schema/src/domains/experiment/BlockedMessageGroupsSchemas';
|
||||
import {
|
||||
DEFAULT_EXPERIMENT_DELIVERY_CONFIG,
|
||||
type ExperimentDeliveryConfig,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {
|
||||
DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG,
|
||||
type MessageHoverTrackingConfig,
|
||||
} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas';
|
||||
import {
|
||||
DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG,
|
||||
type MessageKeyboardFocusConfig,
|
||||
} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas';
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
|
||||
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 EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
|
||||
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
|
||||
@@ -312,6 +327,141 @@ describe('InstanceConfigRepository', () => {
|
||||
await expect(repository.getVoiceNoiseSuppressionConfig()).resolves.toEqual(config);
|
||||
});
|
||||
|
||||
it('returns the default message hover tracking config when the key is absent', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await expect(repository.getMessageHoverTrackingConfig()).resolves.toEqual(DEFAULT_MESSAGE_HOVER_TRACKING_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 message hover tracking config for $name', async ({stored}) => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(MESSAGE_HOVER_TRACKING_CONFIG_KEY, stored);
|
||||
|
||||
await expect(repository.getMessageHoverTrackingConfig()).resolves.toEqual(DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG);
|
||||
});
|
||||
|
||||
it('round-trips a stored message hover tracking config', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
const config: MessageHoverTrackingConfig = {
|
||||
...DEFAULT_MESSAGE_HOVER_TRACKING_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 5,
|
||||
rollout_basis_points: 2500,
|
||||
rollout_salt: 'message-hover-tracking-v2',
|
||||
included_user_ids: ['1400000000000000001'],
|
||||
excluded_user_ids: ['1400000000000000002'],
|
||||
};
|
||||
await repository.setMessageHoverTrackingConfig(config);
|
||||
|
||||
await expect(repository.getMessageHoverTrackingConfig()).resolves.toEqual(config);
|
||||
});
|
||||
|
||||
it('returns the default message keyboard focus config when the key is absent', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await expect(repository.getMessageKeyboardFocusConfig()).resolves.toEqual(DEFAULT_MESSAGE_KEYBOARD_FOCUS_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 message keyboard focus config for $name', async ({stored}) => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(MESSAGE_KEYBOARD_FOCUS_CONFIG_KEY, stored);
|
||||
|
||||
await expect(repository.getMessageKeyboardFocusConfig()).resolves.toEqual(DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG);
|
||||
});
|
||||
|
||||
it('round-trips a stored message keyboard focus config', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
const config: MessageKeyboardFocusConfig = {
|
||||
...DEFAULT_MESSAGE_KEYBOARD_FOCUS_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 5,
|
||||
rollout_basis_points: 2500,
|
||||
rollout_salt: 'message-keyboard-focus-v2',
|
||||
included_user_ids: ['1400000000000000001'],
|
||||
excluded_user_ids: ['1400000000000000002'],
|
||||
};
|
||||
await repository.setMessageKeyboardFocusConfig(config);
|
||||
|
||||
await expect(repository.getMessageKeyboardFocusConfig()).resolves.toEqual(config);
|
||||
});
|
||||
|
||||
it('returns the default blocked message groups config when the key is absent', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await expect(repository.getBlockedMessageGroupsConfig()).resolves.toEqual(DEFAULT_BLOCKED_MESSAGE_GROUPS_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 blocked message groups config for $name', async ({stored}) => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(BLOCKED_MESSAGE_GROUPS_CONFIG_KEY, stored);
|
||||
|
||||
await expect(repository.getBlockedMessageGroupsConfig()).resolves.toEqual(DEFAULT_BLOCKED_MESSAGE_GROUPS_CONFIG);
|
||||
});
|
||||
|
||||
it('round-trips a stored blocked message groups config', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
const config: BlockedMessageGroupsConfig = {
|
||||
...DEFAULT_BLOCKED_MESSAGE_GROUPS_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 5,
|
||||
rollout_basis_points: 2500,
|
||||
rollout_salt: 'blocked-message-groups-v2',
|
||||
included_user_ids: ['1400000000000000001'],
|
||||
excluded_user_ids: ['1400000000000000002'],
|
||||
};
|
||||
await repository.setBlockedMessageGroupsConfig(config);
|
||||
|
||||
await expect(repository.getBlockedMessageGroupsConfig()).resolves.toEqual(config);
|
||||
});
|
||||
|
||||
it('fills newly added voice noise suppression fields from the schema defaults', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
|
||||
@@ -32,10 +32,22 @@ import {
|
||||
type VoiceNoiseSuppressionConfig,
|
||||
VoiceNoiseSuppressionConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {
|
||||
type BlockedMessageGroupsConfig,
|
||||
BlockedMessageGroupsConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/experiment/BlockedMessageGroupsSchemas';
|
||||
import {
|
||||
type ExperimentDeliveryConfig,
|
||||
ExperimentDeliveryConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {
|
||||
type MessageHoverTrackingConfig,
|
||||
MessageHoverTrackingConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas';
|
||||
import {
|
||||
type MessageKeyboardFocusConfig,
|
||||
MessageKeyboardFocusConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas';
|
||||
import {
|
||||
type InstanceAppPublic,
|
||||
InstanceAppPublicSchema,
|
||||
@@ -55,6 +67,9 @@ import {z} from 'zod';
|
||||
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
|
||||
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
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 REGISTRATION_CONFIG_KEY = 'registration_config';
|
||||
const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
|
||||
@@ -338,6 +353,9 @@ type StoredConfigSection =
|
||||
| 'gateway rollout'
|
||||
| 'voice noise suppression'
|
||||
| 'experiment delivery'
|
||||
| 'message hover tracking'
|
||||
| 'message keyboard focus'
|
||||
| 'blocked message groups'
|
||||
| 'instance policy'
|
||||
| 'integrations'
|
||||
| 'media'
|
||||
@@ -479,6 +497,18 @@ function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeli
|
||||
return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery');
|
||||
}
|
||||
|
||||
function parseStoredMessageHoverTrackingConfig(raw: string | null): MessageHoverTrackingConfig {
|
||||
return parseStoredConfigOrDefault(MessageHoverTrackingConfigSchema, raw, 'message hover tracking');
|
||||
}
|
||||
|
||||
function parseStoredMessageKeyboardFocusConfig(raw: string | null): MessageKeyboardFocusConfig {
|
||||
return parseStoredConfigOrDefault(MessageKeyboardFocusConfigSchema, raw, 'message keyboard focus');
|
||||
}
|
||||
|
||||
function parseStoredBlockedMessageGroupsConfig(raw: string | null): BlockedMessageGroupsConfig {
|
||||
return parseStoredConfigOrDefault(BlockedMessageGroupsConfigSchema, raw, 'blocked message groups');
|
||||
}
|
||||
|
||||
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`);
|
||||
@@ -998,6 +1028,9 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
|
||||
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
|
||||
parseStoredMessageHoverTrackingConfig(snapshot.get(MESSAGE_HOVER_TRACKING_CONFIG_KEY) ?? null);
|
||||
parseStoredMessageKeyboardFocusConfig(snapshot.get(MESSAGE_KEYBOARD_FOCUS_CONFIG_KEY) ?? null);
|
||||
parseStoredBlockedMessageGroupsConfig(snapshot.get(BLOCKED_MESSAGE_GROUPS_CONFIG_KEY) ?? null);
|
||||
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
|
||||
checkStoredConfig('registration', () =>
|
||||
parseStoredRegistrationConfig(snapshot.get(REGISTRATION_CONFIG_KEY) ?? null),
|
||||
@@ -1083,6 +1116,36 @@ export class InstanceConfigRepository {
|
||||
return parseStoredExperimentDeliveryConfig(raw);
|
||||
}
|
||||
|
||||
async getMessageHoverTrackingConfig(): Promise<MessageHoverTrackingConfig> {
|
||||
const raw = await this.getConfig(MESSAGE_HOVER_TRACKING_CONFIG_KEY);
|
||||
return parseStoredMessageHoverTrackingConfig(raw);
|
||||
}
|
||||
|
||||
async setMessageHoverTrackingConfig(config: MessageHoverTrackingConfig): Promise<void> {
|
||||
const validated = validateStoredConfig(MessageHoverTrackingConfigSchema, config, 'message hover tracking');
|
||||
await this.setConfig(MESSAGE_HOVER_TRACKING_CONFIG_KEY, JSON.stringify(validated));
|
||||
}
|
||||
|
||||
async getMessageKeyboardFocusConfig(): Promise<MessageKeyboardFocusConfig> {
|
||||
const raw = await this.getConfig(MESSAGE_KEYBOARD_FOCUS_CONFIG_KEY);
|
||||
return parseStoredMessageKeyboardFocusConfig(raw);
|
||||
}
|
||||
|
||||
async setMessageKeyboardFocusConfig(config: MessageKeyboardFocusConfig): Promise<void> {
|
||||
const validated = validateStoredConfig(MessageKeyboardFocusConfigSchema, config, 'message keyboard focus');
|
||||
await this.setConfig(MESSAGE_KEYBOARD_FOCUS_CONFIG_KEY, JSON.stringify(validated));
|
||||
}
|
||||
|
||||
async getBlockedMessageGroupsConfig(): Promise<BlockedMessageGroupsConfig> {
|
||||
const raw = await this.getConfig(BLOCKED_MESSAGE_GROUPS_CONFIG_KEY);
|
||||
return parseStoredBlockedMessageGroupsConfig(raw);
|
||||
}
|
||||
|
||||
async setBlockedMessageGroupsConfig(config: BlockedMessageGroupsConfig): Promise<void> {
|
||||
const validated = validateStoredConfig(BlockedMessageGroupsConfigSchema, config, 'blocked message groups');
|
||||
await this.setConfig(BLOCKED_MESSAGE_GROUPS_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));
|
||||
|
||||
@@ -28258,7 +28258,10 @@
|
||||
"assignments": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"}
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"},
|
||||
"message_hover_tracking": {"$ref": "#/components/schemas/MessageHoverTrackingAssignmentResponse"},
|
||||
"message_keyboard_focus": {"$ref": "#/components/schemas/MessageKeyboardFocusAssignmentResponse"},
|
||||
"blocked_message_groups": {"$ref": "#/components/schemas/BlockedMessageGroupsAssignmentResponse"}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -31996,6 +31999,39 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try"]},
|
||||
"BlockedMessageGroupsAssignmentResponse": {
|
||||
"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
|
||||
},
|
||||
"MessageKeyboardFocusAssignmentResponse": {
|
||||
"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
|
||||
},
|
||||
"MessageHoverTrackingAssignmentResponse": {
|
||||
"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
|
||||
},
|
||||
"VoiceNoiseSuppressionAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -21,6 +21,12 @@ import {useServiceWorkerBadge} from '@app/features/app/hooks/useServiceWorkerBad
|
||||
import {useTabKeyFocusGuard} from '@app/features/app/hooks/useTabKeyFocusGuard';
|
||||
import {type LayoutVariant, LayoutVariantProvider} from '@app/features/app/state/LayoutVariantContext';
|
||||
import RuntimeCrash from '@app/features/app/state/RuntimeCrash';
|
||||
import BlockedMessageGroupsRollout, {
|
||||
BLOCKED_MESSAGE_GROUPS_EXPERIMENT_CLASS,
|
||||
} from '@app/features/channel/state/BlockedMessageGroupsRollout';
|
||||
import MessageHoverTrackingRollout, {
|
||||
MESSAGE_HOVER_TRACKING_EXPERIMENT_CLASS,
|
||||
} from '@app/features/channel/state/MessageHoverTrackingRollout';
|
||||
import {showMyselfTypingHelper} from '@app/features/devtools/utils/ShowMyselfTypingHelper';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
|
||||
@@ -154,6 +160,8 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
useDocumentClassToggle('reduced-motion', reducedMotion);
|
||||
useDocumentClassToggle('mobile-layout', MobileLayout.platformMobileDetected || MobileLayout.enabled);
|
||||
useDocumentClassToggle(UNFOCUSED_FULLY_INTERACTIVE_CLASS, stayInteractiveWhenUnfocused);
|
||||
useDocumentClassToggle(MESSAGE_HOVER_TRACKING_EXPERIMENT_CLASS, MessageHoverTrackingRollout.enabled);
|
||||
useDocumentClassToggle(BLOCKED_MESSAGE_GROUPS_EXPERIMENT_CLASS, BlockedMessageGroupsRollout.enabled);
|
||||
useDesktopAllowTransparency(isNative);
|
||||
useWindowEventListeners({preventDocumentScroll: !isNative});
|
||||
useRemScaleTracking();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import {CHANNEL_TEXTAREA_SELECTOR} from '@app/features/app/keybindings/utils/EditableElement';
|
||||
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
|
||||
import {useLocation} from '@app/features/platform/components/router/RouterReact';
|
||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||
import FocusRingManager from '@app/features/ui/focus_ring/FocusRingManager';
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
recordPointerActivationFocusTarget,
|
||||
} from '@app/features/ui/utils/PointerActivationFocus';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useEffect, useMemo} from 'react';
|
||||
import {useEffect, useLayoutEffect, useMemo} from 'react';
|
||||
|
||||
const FOCUS_TRAPPING_OVERLAY_SELECTOR = [
|
||||
'[role="dialog"]',
|
||||
@@ -86,9 +87,15 @@ export const KeyboardModeListener = observer(() => {
|
||||
window.removeEventListener('pointerdown', handlePointer, true);
|
||||
};
|
||||
}, [isAuthRoute]);
|
||||
useEffect(() => {
|
||||
const keyboardNavigationEnabled = MessageKeyboardFocusRollout.enabled;
|
||||
useLayoutEffect(() => {
|
||||
if (!keyboardNavigationEnabled) return;
|
||||
FocusRingManager.setRingsEnabled(keyboardModeEnabled);
|
||||
}, [keyboardModeEnabled]);
|
||||
}, [keyboardModeEnabled, keyboardNavigationEnabled]);
|
||||
useEffect(() => {
|
||||
if (keyboardNavigationEnabled) return;
|
||||
FocusRingManager.setRingsEnabled(keyboardModeEnabled);
|
||||
}, [keyboardModeEnabled, keyboardNavigationEnabled]);
|
||||
useEffect(() => {
|
||||
const pendingFrames = new Set<number>();
|
||||
const handlePointerActivation = (event: MouseEvent) => {
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
import ContextMenu, {isContextMenuNodeTarget} from '@app/features/ui/state/ContextMenu';
|
||||
import {autorun} from 'mobx';
|
||||
import {type RefObject, useEffect, useState} from 'react';
|
||||
import {type RefObject, useEffect, useRef, useState} from 'react';
|
||||
|
||||
interface ContextMenuHoverSubscriber {
|
||||
readonly elementRef: RefObject<HTMLElement | null>;
|
||||
readonly setContextMenuOpen: (contextMenuOpen: boolean) => void;
|
||||
contextMenuOpen: boolean;
|
||||
}
|
||||
|
||||
const contextMenuHoverSubscribers = new Set<ContextMenuHoverSubscriber>();
|
||||
@@ -28,10 +27,7 @@ function syncContextMenuHoverSubscribers(): void {
|
||||
const chain = resolveContextMenuTargetChain();
|
||||
for (const subscriber of Array.from(contextMenuHoverSubscribers)) {
|
||||
const element = subscriber.elementRef.current;
|
||||
const contextMenuOpen = chain != null && element != null && chain.has(element);
|
||||
if (subscriber.contextMenuOpen === contextMenuOpen) continue;
|
||||
subscriber.contextMenuOpen = contextMenuOpen;
|
||||
subscriber.setContextMenuOpen(contextMenuOpen);
|
||||
subscriber.setContextMenuOpen(chain != null && element != null && chain.has(element));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,12 +48,22 @@ function subscribeContextMenuHover(subscriber: ContextMenuHoverSubscriber): () =
|
||||
|
||||
export function useContextMenuHoverState(elementRef: RefObject<HTMLElement | null>, enabled: boolean = true): boolean {
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||
const publishedContextMenuOpenRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const publishContextMenuOpen = (nextContextMenuOpen: boolean) => {
|
||||
if (publishedContextMenuOpenRef.current === nextContextMenuOpen) return;
|
||||
publishedContextMenuOpenRef.current = nextContextMenuOpen;
|
||||
setContextMenuOpen(nextContextMenuOpen);
|
||||
};
|
||||
if (!enabled) {
|
||||
setContextMenuOpen(false);
|
||||
publishContextMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
return subscribeContextMenuHover({elementRef, setContextMenuOpen, contextMenuOpen: false});
|
||||
const unsubscribe = subscribeContextMenuHover({elementRef, setContextMenuOpen: publishContextMenuOpen});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
publishContextMenuOpen(false);
|
||||
};
|
||||
}, [elementRef, enabled]);
|
||||
return contextMenuOpen;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,28 @@
|
||||
|
||||
.container {
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
|
||||
:global(html:not(.experiment-blocked-message-groups)) .container {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
:global(html.experiment-blocked-message-groups) .container {
|
||||
margin-left: calc(-1 * var(--chat-mobile-horizontal-padding));
|
||||
margin-right: calc(-1 * var(--chat-mobile-horizontal-padding));
|
||||
padding-left: var(--chat-mobile-horizontal-padding);
|
||||
padding-right: var(--chat-mobile-horizontal-padding);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
:global(html.experiment-blocked-message-groups) .container {
|
||||
margin-left: calc(-1 * var(--chat-horizontal-padding));
|
||||
margin-right: calc(-1 * var(--chat-horizontal-padding));
|
||||
padding-left: var(--chat-horizontal-padding);
|
||||
padding-right: var(--chat-horizontal-padding);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {act, createElement, type ReactNode} from 'react';
|
||||
import {createRoot, type Root} from 'react-dom/client';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const rolloutMock = {enabled: true};
|
||||
const ChannelStreamType = {
|
||||
MESSAGE: 'MESSAGE',
|
||||
MESSAGE_GROUP_BLOCKED: 'MESSAGE_GROUP_BLOCKED',
|
||||
MESSAGE_GROUP_IGNORED: 'MESSAGE_GROUP_IGNORED',
|
||||
MESSAGE_GROUP_SPAMMER: 'MESSAGE_GROUP_SPAMMER',
|
||||
DIVIDER: 'DIVIDER',
|
||||
} as const;
|
||||
|
||||
vi.mock('@app/features/messaging/utils/MessageGroupingUtils', () => ({ChannelStreamType}));
|
||||
|
||||
vi.mock('@app/features/channel/state/BlockedMessageGroupsRollout', () => ({default: rolloutMock}));
|
||||
vi.mock('@lingui/core/macro', () => ({msg: (value: unknown) => value}));
|
||||
vi.mock('@lingui/react/macro', () => ({useLingui: () => ({i18n: {_: () => 'blocked messages'}})}));
|
||||
vi.mock('@app/features/channel/components/ChannelDivider', () => ({
|
||||
Divider: ({children}: {children?: ReactNode}) => createElement('div', {'data-divider': true}, children),
|
||||
}));
|
||||
vi.mock('@app/features/channel/components/MessageGroup', () => ({
|
||||
MessageGroup: () => createElement('div', {'data-message-group': true}),
|
||||
}));
|
||||
|
||||
const {BlockedMessageGroups} = await import('@app/features/channel/components/BlockedMessageGroups');
|
||||
|
||||
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const CHANNEL = {id: 'channel-1', guild_id: null} as never;
|
||||
const SPACER_SELECTOR = '[data-flx="channel.blocked-message-groups.group-spacer"]';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let consoleError: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
function message(id: string): Record<string, unknown> {
|
||||
return {id, author: {id: 'author-1'}, blocked: true};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
rolloutMock.enabled = true;
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
function renderRevealedGroup(messageGroups: Array<unknown>): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(BlockedMessageGroups, {
|
||||
channel: CHANNEL,
|
||||
messageGroups: messageGroups as never,
|
||||
onReveal: () => undefined,
|
||||
revealed: true,
|
||||
compact: false,
|
||||
messageGroupSpacing: 8,
|
||||
variant: 'blocked',
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function duplicateKeyWarnings(): Array<unknown> {
|
||||
return consoleError.mock.calls.filter((call: Array<unknown>) => String(call[0]).includes('same key'));
|
||||
}
|
||||
|
||||
const DIVIDER_INSIDE_GROUP = [
|
||||
{type: ChannelStreamType.MESSAGE, content: message('100'), contentKey: '100', groupId: 'g1'},
|
||||
{type: ChannelStreamType.DIVIDER, content: '', unreadId: '200', contentKey: 'divider-200'},
|
||||
{type: ChannelStreamType.MESSAGE, content: message('200'), contentKey: '200', groupId: 'g2'},
|
||||
];
|
||||
|
||||
describe('BlockedMessageGroups experiment arm', () => {
|
||||
it('keys an unread divider apart from the message group below it', () => {
|
||||
renderRevealedGroup(DIVIDER_INSIDE_GROUP);
|
||||
|
||||
expect(duplicateKeyWarnings()).toEqual([]);
|
||||
expect(container.querySelectorAll('[data-message-group]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('skips a divider that leads the revealed group', () => {
|
||||
renderRevealedGroup([
|
||||
{type: ChannelStreamType.DIVIDER, content: '', unreadId: '100', contentKey: 'divider-100'},
|
||||
{type: ChannelStreamType.MESSAGE, content: message('100'), contentKey: '100', groupId: 'g1'},
|
||||
]);
|
||||
|
||||
expect(duplicateKeyWarnings()).toEqual([]);
|
||||
expect(container.querySelectorAll('[data-message-group]')).toHaveLength(1);
|
||||
expect(container.querySelectorAll('[data-divider]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('spaces consecutive groups apart inside the revealed block', () => {
|
||||
renderRevealedGroup([
|
||||
{type: ChannelStreamType.MESSAGE, content: message('100'), contentKey: '100', groupId: 'g1'},
|
||||
{type: ChannelStreamType.MESSAGE, content: message('200'), contentKey: '200', groupId: 'g2'},
|
||||
{type: ChannelStreamType.MESSAGE, content: message('300'), contentKey: '300', groupId: 'g3'},
|
||||
]);
|
||||
|
||||
expect(container.querySelectorAll('[data-message-group]')).toHaveLength(3);
|
||||
expect(container.querySelectorAll(SPACER_SELECTOR)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BlockedMessageGroups control arm', () => {
|
||||
beforeEach(() => {
|
||||
rolloutMock.enabled = false;
|
||||
});
|
||||
|
||||
it('renders no group spacers', () => {
|
||||
renderRevealedGroup([
|
||||
{type: ChannelStreamType.MESSAGE, content: message('100'), contentKey: '100', groupId: 'g1'},
|
||||
{type: ChannelStreamType.MESSAGE, content: message('200'), contentKey: '200', groupId: 'g2'},
|
||||
]);
|
||||
|
||||
expect(container.querySelectorAll('[data-message-group]')).toHaveLength(2);
|
||||
expect(container.querySelectorAll(SPACER_SELECTOR)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -2,19 +2,24 @@
|
||||
|
||||
import styles from '@app/features/channel/components/BlockedMessageGroups.module.css';
|
||||
import {Divider} from '@app/features/channel/components/ChannelDivider';
|
||||
import streamStyles from '@app/features/channel/components/ChannelMessages.module.css';
|
||||
import {
|
||||
MessageGroup,
|
||||
type MessageGroupProps,
|
||||
type MessageGroupRenderWrapperProps,
|
||||
} from '@app/features/channel/components/MessageGroup';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import BlockedMessageGroupsRollout from '@app/features/channel/state/BlockedMessageGroupsRollout';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
|
||||
import {type ChannelStreamItem, ChannelStreamType} from '@app/features/messaging/utils/MessageGroupingUtils';
|
||||
import {getMessageSelector} from '@app/features/messaging/utils/MessageNodeSelectors';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import type {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
|
||||
import React, {useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef} from 'react';
|
||||
|
||||
const MESSAGE_SCROLLER_SELECTOR = '[data-fluxer-scroll-container="true"]';
|
||||
const SCROLLER_BOTTOM_EPSILON = 1;
|
||||
@@ -97,9 +102,16 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
renderMessageWrapper,
|
||||
suppressUnreadIndicator,
|
||||
} = props;
|
||||
const groupRenderingEnabled = BlockedMessageGroupsRollout.enabled;
|
||||
const {i18n} = useLingui();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const scrollToBottomFrameRef = useRef<number | null>(null);
|
||||
const wasRevealedRef = useRef(revealed);
|
||||
const revealedByKeyboardRef = useRef(false);
|
||||
const focusWithinContentRef = useRef(false);
|
||||
const contentId = useId();
|
||||
const messageSummary = useMemo(() => {
|
||||
let firstMessageId: string | null = null;
|
||||
let totalMessageCount = 0;
|
||||
@@ -123,34 +135,93 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
scroller.scrollTop = scroller.scrollHeight;
|
||||
});
|
||||
}, []);
|
||||
const handleClick = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const scroller = container?.closest(MESSAGE_SCROLLER_SELECTOR) as HTMLElement | null;
|
||||
if (scroller) {
|
||||
const wasAtBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < SCROLLER_BOTTOM_EPSILON;
|
||||
if (revealed) {
|
||||
onReveal(null);
|
||||
if (wasAtBottom) {
|
||||
scheduleScrollToBottom(scroller);
|
||||
}
|
||||
} else {
|
||||
if (messageSummary.firstMessageId) {
|
||||
onReveal(messageSummary.firstMessageId);
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
revealedByKeyboardRef.current = event.detail === 0;
|
||||
const container = containerRef.current;
|
||||
const scroller = container?.closest(MESSAGE_SCROLLER_SELECTOR) as HTMLElement | null;
|
||||
if (scroller) {
|
||||
const wasAtBottom =
|
||||
scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < SCROLLER_BOTTOM_EPSILON;
|
||||
if (revealed) {
|
||||
onReveal(null);
|
||||
if (wasAtBottom) {
|
||||
scheduleScrollToBottom(scroller);
|
||||
}
|
||||
} else {
|
||||
if (messageSummary.firstMessageId) {
|
||||
onReveal(messageSummary.firstMessageId);
|
||||
if (wasAtBottom) {
|
||||
scheduleScrollToBottom(scroller);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (revealed) {
|
||||
onReveal(null);
|
||||
} else {
|
||||
if (messageSummary.firstMessageId) {
|
||||
onReveal(messageSummary.firstMessageId);
|
||||
if (revealed) {
|
||||
onReveal(null);
|
||||
} else {
|
||||
if (messageSummary.firstMessageId) {
|
||||
onReveal(messageSummary.firstMessageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[messageSummary.firstMessageId, onReveal, revealed, scheduleScrollToBottom],
|
||||
);
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (container == null) {
|
||||
return;
|
||||
}
|
||||
}, [messageSummary.firstMessageId, onReveal, revealed, scheduleScrollToBottom]);
|
||||
const isInsideContent = (node: EventTarget | null): boolean =>
|
||||
node instanceof Node && contentRef.current?.contains(node) === true;
|
||||
const handleFocusIn = (event: FocusEvent) => {
|
||||
focusWithinContentRef.current = isInsideContent(event.target);
|
||||
};
|
||||
const handleFocusOut = (event: FocusEvent) => {
|
||||
if (isInsideContent(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
focusWithinContentRef.current = false;
|
||||
};
|
||||
container.addEventListener('focusin', handleFocusIn);
|
||||
container.addEventListener('focusout', handleFocusOut);
|
||||
return () => {
|
||||
container.removeEventListener('focusin', handleFocusIn);
|
||||
container.removeEventListener('focusout', handleFocusOut);
|
||||
};
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
const wasRevealed = wasRevealedRef.current;
|
||||
wasRevealedRef.current = revealed;
|
||||
if (wasRevealed === revealed) {
|
||||
return;
|
||||
}
|
||||
const revealedByKeyboard = revealedByKeyboardRef.current;
|
||||
revealedByKeyboardRef.current = false;
|
||||
if (!KeyboardMode.keyboardModeEnabled || !MessageKeyboardFocusRollout.enabled) {
|
||||
focusWithinContentRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (revealed) {
|
||||
if (!revealedByKeyboard) {
|
||||
return;
|
||||
}
|
||||
const firstMessage = contentRef.current?.querySelector<HTMLElement>(getMessageSelector(channel.id));
|
||||
if (firstMessage == null) {
|
||||
return;
|
||||
}
|
||||
if (firstMessage.tabIndex < 0) {
|
||||
firstMessage.tabIndex = -1;
|
||||
}
|
||||
firstMessage.focus({preventScroll: true});
|
||||
return;
|
||||
}
|
||||
if (focusWithinContentRef.current) {
|
||||
focusWithinContentRef.current = false;
|
||||
toggleRef.current?.focus({preventScroll: true});
|
||||
}
|
||||
}, [channel.id, revealed]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollToBottomFrameRef.current != null) {
|
||||
@@ -163,8 +234,20 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
const nodes: Array<React.ReactNode> = [];
|
||||
let currentGroupMessages: Array<Message> = [];
|
||||
let groupId: string | undefined;
|
||||
let renderedGroupCount = 0;
|
||||
const flushGroup = () => {
|
||||
if (currentGroupMessages.length > 0) {
|
||||
if (groupRenderingEnabled && renderedGroupCount > 0 && messageGroupSpacing > 0) {
|
||||
nodes.push(
|
||||
<div
|
||||
key={`blocked-group-spacer-${currentGroupMessages[0].id}`}
|
||||
className={streamStyles.groupSpacer}
|
||||
aria-hidden="true"
|
||||
data-flx="channel.blocked-message-groups.group-spacer"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
renderedGroupCount += 1;
|
||||
nodes.push(
|
||||
<MessageGroup
|
||||
key={currentGroupMessages[0].id}
|
||||
@@ -196,7 +279,13 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
flushGroup();
|
||||
nodes.push(
|
||||
<Divider
|
||||
key={item.unreadId || item.contentKey || `divider-${itemIndex}`}
|
||||
key={
|
||||
groupRenderingEnabled
|
||||
? item.unreadId
|
||||
? `unread-divider-${item.unreadId}`
|
||||
: item.contentKey || `divider-${itemIndex}`
|
||||
: item.unreadId || item.contentKey || `divider-${itemIndex}`
|
||||
}
|
||||
spacing={messageGroupSpacing}
|
||||
red={!!item.unreadId}
|
||||
id={item.unreadId ? 'new-messages-bar' : undefined}
|
||||
@@ -230,6 +319,7 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
renderMessageActions,
|
||||
renderMessageWrapper,
|
||||
suppressUnreadIndicator,
|
||||
groupRenderingEnabled,
|
||||
]);
|
||||
const leadingUnreadDivider = messageGroups[0]?.type === ChannelStreamType.DIVIDER && !!messageGroups[0].unreadId;
|
||||
return (
|
||||
@@ -247,9 +337,12 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
ref={toggleRef}
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
onClick={handleClick}
|
||||
aria-expanded={revealed}
|
||||
aria-controls={contentId}
|
||||
data-flx="channel.blocked-message-groups.toggle.click.button"
|
||||
>
|
||||
{variant === 'spammer'
|
||||
@@ -257,7 +350,13 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
: i18n._(BLOCKED_MESSAGES_DESCRIPTOR, {count: messageSummary.totalMessageCount})}
|
||||
</button>
|
||||
{revealed && (
|
||||
<div className={styles.content} data-blocked-messages data-flx="channel.blocked-message-groups.content">
|
||||
<div
|
||||
ref={contentRef}
|
||||
id={contentId}
|
||||
className={styles.content}
|
||||
data-blocked-messages
|
||||
data-flx="channel.blocked-message-groups.content"
|
||||
>
|
||||
{messageNodes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {isMediaOnlyEmbed} from '@app/features/channel/components/embeds/EmbedRen
|
||||
import {MessageActionBar, MessageActionBarCore} from '@app/features/channel/components/MessageActionBar';
|
||||
import {MessageActionBottomSheet} from '@app/features/channel/components/MessageActionBottomSheet';
|
||||
import {requestDeleteMessage} from '@app/features/channel/components/MessageActionUtils';
|
||||
import {useMessageHoverState} from '@app/features/channel/components/MessageHoverState';
|
||||
import {MessageViewContextProvider} from '@app/features/channel/components/MessageViewContext';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
@@ -14,12 +15,12 @@ import {MarkdownContext} from '@app/features/messaging/components/markdown/rende
|
||||
import type {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage';
|
||||
import MessageEdit from '@app/features/messaging/state/MessageEdit';
|
||||
import MessageFocus from '@app/features/messaging/state/MessageFocus';
|
||||
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
|
||||
import MessageReply from '@app/features/messaging/state/MessageReply';
|
||||
import {getMessageComponent} from '@app/features/messaging/utils/MessageComponentUtils';
|
||||
import {renderAstToPlaintext} from '@app/features/messaging/utils/markdown/Plaintext';
|
||||
import {NodeType} from '@app/features/messaging/utils/markdown/parser/Enums';
|
||||
import {SystemMessageUtils} from '@app/features/messaging/utils/SystemMessageUtils';
|
||||
import {subscribeWindowFocus} from '@app/features/platform/utils/WindowFocusBroadcast';
|
||||
import * as ReadStateCommands from '@app/features/read_state/commands/ReadStateCommands';
|
||||
import styles from '@app/features/theme/styles/Message.module.css';
|
||||
import {MessageContextMenu} from '@app/features/ui/action_menu/MessageContextMenu';
|
||||
@@ -38,7 +39,7 @@ import {useLingui} from '@lingui/react/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const ATTACHMENT_DESCRIPTOR = msg({
|
||||
message: 'attachment',
|
||||
@@ -164,149 +165,6 @@ const truncateAriaLabelText = (text: string): string => {
|
||||
}
|
||||
return `${normalized.slice(0, MAX_ARIA_MESSAGE_TEXT_LENGTH - 1).trimEnd()}...`;
|
||||
};
|
||||
const isPointInsideMessageTree = (messageElement: HTMLElement, point: {x: number; y: number}): boolean => {
|
||||
const target = messageElement.ownerDocument.elementFromPoint(point.x, point.y);
|
||||
return Boolean(target && messageElement.contains(target));
|
||||
};
|
||||
const HOVER_SCROLL_IDLE_MS = 150;
|
||||
let lastPointerPosition: {x: number; y: number} | null = null;
|
||||
let pointerPositionNotificationFrame: number | null = null;
|
||||
let pointerPositionSubscriptionCount = 0;
|
||||
let hoverInvalidationSubscriptionCount = 0;
|
||||
let pointerHoverSuspendedByScroll = false;
|
||||
let pointerHoverScrollIdleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const pointerPositionListeners = new Set<() => void>();
|
||||
const hoverInvalidationListeners = new Set<() => void>();
|
||||
const notifyPointerPositionListeners = (): void => {
|
||||
for (const listener of Array.from(pointerPositionListeners)) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
const notifyHoverInvalidationListeners = (): void => {
|
||||
for (const listener of Array.from(hoverInvalidationListeners)) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
const clearPointerHoverScrollIdleTimer = (): void => {
|
||||
if (pointerHoverScrollIdleTimer == null) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pointerHoverScrollIdleTimer);
|
||||
pointerHoverScrollIdleTimer = null;
|
||||
};
|
||||
const resumePointerHoverAfterScroll = (): void => {
|
||||
clearPointerHoverScrollIdleTimer();
|
||||
if (!pointerHoverSuspendedByScroll) {
|
||||
return;
|
||||
}
|
||||
pointerHoverSuspendedByScroll = false;
|
||||
notifyHoverInvalidationListeners();
|
||||
};
|
||||
const suspendPointerHoverForScroll = (): void => {
|
||||
clearPointerHoverScrollIdleTimer();
|
||||
pointerHoverScrollIdleTimer = setTimeout(resumePointerHoverAfterScroll, HOVER_SCROLL_IDLE_MS);
|
||||
if (pointerHoverSuspendedByScroll) {
|
||||
return;
|
||||
}
|
||||
pointerHoverSuspendedByScroll = true;
|
||||
if (pointerPositionNotificationFrame != null) {
|
||||
cancelAnimationFrame(pointerPositionNotificationFrame);
|
||||
pointerPositionNotificationFrame = null;
|
||||
}
|
||||
notifyHoverInvalidationListeners();
|
||||
};
|
||||
const schedulePointerPositionNotification = (): void => {
|
||||
if (pointerPositionNotificationFrame != null) {
|
||||
return;
|
||||
}
|
||||
pointerPositionNotificationFrame = requestAnimationFrame(() => {
|
||||
pointerPositionNotificationFrame = null;
|
||||
notifyPointerPositionListeners();
|
||||
});
|
||||
};
|
||||
const updateLastPointerPosition = (event: PointerEvent | MouseEvent): void => {
|
||||
if (lastPointerPosition?.x === event.clientX && lastPointerPosition.y === event.clientY) {
|
||||
return;
|
||||
}
|
||||
lastPointerPosition = {x: event.clientX, y: event.clientY};
|
||||
resumePointerHoverAfterScroll();
|
||||
schedulePointerPositionNotification();
|
||||
};
|
||||
const clearLastPointerPosition = (): void => {
|
||||
if (!lastPointerPosition) {
|
||||
return;
|
||||
}
|
||||
lastPointerPosition = null;
|
||||
schedulePointerPositionNotification();
|
||||
};
|
||||
const clearLastPointerPositionOnWindowBlur = (): void => {
|
||||
clearLastPointerPosition();
|
||||
notifyHoverInvalidationListeners();
|
||||
};
|
||||
const clearLastPointerPositionOnWindowExit = (event: PointerEvent | MouseEvent): void => {
|
||||
if (event.relatedTarget == null) {
|
||||
clearLastPointerPosition();
|
||||
}
|
||||
};
|
||||
const supportsPointerPositionEvents = (): boolean => 'PointerEvent' in window;
|
||||
const subscribePointerPosition = (listener: () => void): (() => void) => {
|
||||
if (pointerPositionSubscriptionCount === 0) {
|
||||
if (supportsPointerPositionEvents()) {
|
||||
window.addEventListener('pointermove', updateLastPointerPosition, true);
|
||||
window.addEventListener('pointerdown', updateLastPointerPosition, true);
|
||||
window.addEventListener('pointerout', clearLastPointerPositionOnWindowExit, true);
|
||||
} else {
|
||||
window.addEventListener('mousemove', updateLastPointerPosition, true);
|
||||
window.addEventListener('mousedown', updateLastPointerPosition, true);
|
||||
window.addEventListener('mouseout', clearLastPointerPositionOnWindowExit, true);
|
||||
}
|
||||
}
|
||||
pointerPositionSubscriptionCount += 1;
|
||||
pointerPositionListeners.add(listener);
|
||||
return () => {
|
||||
pointerPositionListeners.delete(listener);
|
||||
pointerPositionSubscriptionCount = Math.max(0, pointerPositionSubscriptionCount - 1);
|
||||
if (pointerPositionSubscriptionCount !== 0) {
|
||||
return;
|
||||
}
|
||||
lastPointerPosition = null;
|
||||
if (pointerPositionNotificationFrame != null) {
|
||||
cancelAnimationFrame(pointerPositionNotificationFrame);
|
||||
pointerPositionNotificationFrame = null;
|
||||
}
|
||||
if (supportsPointerPositionEvents()) {
|
||||
window.removeEventListener('pointermove', updateLastPointerPosition, true);
|
||||
window.removeEventListener('pointerdown', updateLastPointerPosition, true);
|
||||
window.removeEventListener('pointerout', clearLastPointerPositionOnWindowExit, true);
|
||||
} else {
|
||||
window.removeEventListener('mousemove', updateLastPointerPosition, true);
|
||||
window.removeEventListener('mousedown', updateLastPointerPosition, true);
|
||||
window.removeEventListener('mouseout', clearLastPointerPositionOnWindowExit, true);
|
||||
}
|
||||
};
|
||||
};
|
||||
const subscribeMessageHoverInvalidation = (listener: () => void): (() => void) => {
|
||||
if (hoverInvalidationSubscriptionCount === 0) {
|
||||
window.addEventListener('scroll', suspendPointerHoverForScroll, true);
|
||||
window.addEventListener('resize', notifyHoverInvalidationListeners);
|
||||
window.addEventListener('blur', clearLastPointerPositionOnWindowBlur);
|
||||
}
|
||||
hoverInvalidationSubscriptionCount += 1;
|
||||
hoverInvalidationListeners.add(listener);
|
||||
return () => {
|
||||
hoverInvalidationListeners.delete(listener);
|
||||
hoverInvalidationSubscriptionCount = Math.max(0, hoverInvalidationSubscriptionCount - 1);
|
||||
if (hoverInvalidationSubscriptionCount !== 0) {
|
||||
return;
|
||||
}
|
||||
clearPointerHoverScrollIdleTimer();
|
||||
pointerHoverSuspendedByScroll = false;
|
||||
window.removeEventListener('scroll', suspendPointerHoverForScroll, true);
|
||||
window.removeEventListener('resize', notifyHoverInvalidationListeners);
|
||||
window.removeEventListener('blur', clearLastPointerPositionOnWindowBlur);
|
||||
};
|
||||
};
|
||||
|
||||
export type MessageBehaviorOverrides = Partial<{
|
||||
mobileLayoutEnabled: boolean;
|
||||
messageGroupSpacing: number;
|
||||
@@ -362,9 +220,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
|
||||
const {i18n} = useLingui();
|
||||
const [showActionBar, setShowActionBar] = useState(false);
|
||||
const [isLongPressing, setIsLongPressing] = useState(false);
|
||||
const [isHoveringDesktop, setIsHoveringDesktop] = useState(false);
|
||||
const [isFocusedWithin, setIsFocusedWithin] = useState(false);
|
||||
const [isPopoutOpen, setIsPopoutOpen] = useState(false);
|
||||
const [mobileLongPressLinkUrl, setMobileLongPressLinkUrl] = useState<string | undefined>(undefined);
|
||||
const messageRef = useRef<HTMLDivElement | null>(null);
|
||||
const disableContextMenuTracking = behaviorOverrides?.disableContextMenuTracking ?? false;
|
||||
@@ -503,15 +359,6 @@ export const Message: React.FC<MessageProps> = observer((props) => {
|
||||
const velocitySamples = useRef<Array<{x: number; y: number; timestamp: number}>>([]);
|
||||
const highlightTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const suppressClickUntilRef = useRef(0);
|
||||
const popoutCloseRafRef = useRef<number | null>(null);
|
||||
const isHoveringDesktopRef = useRef(false);
|
||||
const setDesktopHoverState = useCallback((isHovered: boolean) => {
|
||||
if (isHoveringDesktopRef.current === isHovered) {
|
||||
return;
|
||||
}
|
||||
isHoveringDesktopRef.current = isHovered;
|
||||
setIsHoveringDesktop(isHovered);
|
||||
}, []);
|
||||
const unsubscribeLongPressScrollCancel = useCallback(() => {
|
||||
unsubscribeLongPressScrollCancelRef.current?.();
|
||||
unsubscribeLongPressScrollCancelRef.current = null;
|
||||
@@ -631,50 +478,13 @@ export const Message: React.FC<MessageProps> = observer((props) => {
|
||||
setMobileLongPressLinkUrl(undefined);
|
||||
}, []);
|
||||
const keyboardModeEnabled = KeyboardMode.keyboardModeEnabled;
|
||||
const isPointerInsideMessage = useCallback((): boolean => {
|
||||
if (mobileLayoutEnabled) {
|
||||
return false;
|
||||
}
|
||||
if (pointerHoverSuspendedByScroll) {
|
||||
return false;
|
||||
}
|
||||
const element = messageRef.current;
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
if (!lastPointerPosition) {
|
||||
return element.matches(':hover');
|
||||
}
|
||||
return isPointInsideMessageTree(element, lastPointerPosition);
|
||||
}, [mobileLayoutEnabled]);
|
||||
const syncPointerHoverState = useCallback((): boolean => {
|
||||
const isHovered = isPointerInsideMessage();
|
||||
setDesktopHoverState(isHovered);
|
||||
return isHovered;
|
||||
}, [isPointerInsideMessage, setDesktopHoverState]);
|
||||
const cancelScheduledPopoutClose = useCallback(() => {
|
||||
if (popoutCloseRafRef.current == null) {
|
||||
return;
|
||||
}
|
||||
cancelAnimationFrame(popoutCloseRafRef.current);
|
||||
popoutCloseRafRef.current = null;
|
||||
}, []);
|
||||
const handleMessagePopoutToggle = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
cancelScheduledPopoutClose();
|
||||
setIsPopoutOpen(true);
|
||||
return;
|
||||
}
|
||||
cancelScheduledPopoutClose();
|
||||
popoutCloseRafRef.current = requestAnimationFrame(() => {
|
||||
popoutCloseRafRef.current = null;
|
||||
syncPointerHoverState();
|
||||
setIsPopoutOpen(false);
|
||||
});
|
||||
},
|
||||
[cancelScheduledPopoutClose, syncPointerHoverState],
|
||||
);
|
||||
const {
|
||||
isHovering,
|
||||
isPopoutOpen,
|
||||
handlePopoutToggle,
|
||||
trackingEnabled: hoverTrackingEnabled,
|
||||
} = useMessageHoverState({messageRef, mobileLayoutEnabled, keyboardModeEnabled, contextMenuOpen});
|
||||
const keyboardNavigationEnabled = MessageKeyboardFocusRollout.enabled;
|
||||
const handleFocusWithin = useCallback(() => {
|
||||
if (!keyboardModeEnabled) {
|
||||
return;
|
||||
@@ -693,58 +503,6 @@ export const Message: React.FC<MessageProps> = observer((props) => {
|
||||
},
|
||||
[channel.id, message.id],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (mobileLayoutEnabled || !messageRef.current) return;
|
||||
const element = messageRef.current;
|
||||
const handleMouseEnter = (event: MouseEvent) => {
|
||||
updateLastPointerPosition(event);
|
||||
if (pointerHoverSuspendedByScroll) {
|
||||
return;
|
||||
}
|
||||
setDesktopHoverState(true);
|
||||
};
|
||||
const handleMouseLeave = (event: MouseEvent) => {
|
||||
updateLastPointerPosition(event);
|
||||
setDesktopHoverState(false);
|
||||
};
|
||||
element.addEventListener('mouseenter', handleMouseEnter);
|
||||
element.addEventListener('mouseleave', handleMouseLeave);
|
||||
const unsubscribeFocus = subscribeWindowFocus(syncPointerHoverState);
|
||||
const unsubscribeHoverInvalidation = subscribeMessageHoverInvalidation(syncPointerHoverState);
|
||||
const rafId = requestAnimationFrame(syncPointerHoverState);
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
element.removeEventListener('mouseenter', handleMouseEnter);
|
||||
element.removeEventListener('mouseleave', handleMouseLeave);
|
||||
unsubscribeFocus();
|
||||
unsubscribeHoverInvalidation();
|
||||
};
|
||||
}, [mobileLayoutEnabled, keyboardModeEnabled, syncPointerHoverState]);
|
||||
const shouldTrackActivePointer = !mobileLayoutEnabled && (isHoveringDesktop || isPopoutOpen || contextMenuOpen);
|
||||
useEffect(() => {
|
||||
if (!shouldTrackActivePointer) {
|
||||
return;
|
||||
}
|
||||
const rafId = requestAnimationFrame(syncPointerHoverState);
|
||||
const unsubscribePointerPosition = subscribePointerPosition(syncPointerHoverState);
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
unsubscribePointerPosition();
|
||||
};
|
||||
}, [shouldTrackActivePointer, syncPointerHoverState]);
|
||||
const wasContextMenuOpenRef = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
const wasOpen = wasContextMenuOpenRef.current;
|
||||
wasContextMenuOpenRef.current = contextMenuOpen;
|
||||
if (wasOpen && !contextMenuOpen) {
|
||||
syncPointerHoverState();
|
||||
}
|
||||
}, [contextMenuOpen, syncPointerHoverState]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelScheduledPopoutClose();
|
||||
};
|
||||
}, [cancelScheduledPopoutClose]);
|
||||
useEffect(() => {
|
||||
if (!keyboardModeEnabled) return;
|
||||
if (contextMenuOpen) {
|
||||
@@ -756,6 +514,17 @@ export const Message: React.FC<MessageProps> = observer((props) => {
|
||||
MessageFocus.clearFocusedMessageIfMatches(channel.id, message.id);
|
||||
}
|
||||
}, [channel, contextMenuOpen, isFocusedWithin, keyboardModeEnabled, message, message.id]);
|
||||
const isFocusedWithinRef = useRef(isFocusedWithin);
|
||||
isFocusedWithinRef.current = isFocusedWithin;
|
||||
const keyboardNavigationEnabledRef = useRef(keyboardNavigationEnabled);
|
||||
keyboardNavigationEnabledRef.current = keyboardNavigationEnabled;
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (keyboardNavigationEnabledRef.current && isFocusedWithinRef.current) {
|
||||
MessageFocus.clearFocusedMessageIfMatches(channel.id, message.id);
|
||||
}
|
||||
};
|
||||
}, [channel.id, message.id]);
|
||||
useEffect(() => {
|
||||
const wasEditing = wasEditingInPreviousUpdateRef.current;
|
||||
const justStartedEditing = !wasEditing && isEditing;
|
||||
@@ -775,7 +544,6 @@ export const Message: React.FC<MessageProps> = observer((props) => {
|
||||
}
|
||||
};
|
||||
}, [unsubscribeLongPressScrollCancel]);
|
||||
const isHovering = mobileLayoutEnabled ? false : isHoveringDesktop;
|
||||
useEffect(() => {
|
||||
if (!keyboardModeEnabled) {
|
||||
setIsFocusedWithin(false);
|
||||
@@ -810,7 +578,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
|
||||
shouldRenderSuppressEmbeds: false,
|
||||
}
|
||||
: undefined,
|
||||
onPopoutToggle: handleMessagePopoutToggle,
|
||||
onPopoutToggle: handlePopoutToggle,
|
||||
suppressMessageActions,
|
||||
onHeadingActivate,
|
||||
}),
|
||||
@@ -824,7 +592,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
|
||||
previewContext,
|
||||
previewOverrides,
|
||||
previewMode,
|
||||
handleMessagePopoutToggle,
|
||||
handlePopoutToggle,
|
||||
suppressMessageActions,
|
||||
onHeadingActivate,
|
||||
],
|
||||
@@ -841,7 +609,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
|
||||
astNodes.length === 1 &&
|
||||
astNodes[0].type === NodeType.Link &&
|
||||
!message.suppressEmbeds;
|
||||
const shouldDisableHoverBackground = prefersReducedMotion && !isEditing;
|
||||
const shouldDisableHoverBackground = !hoverTrackingEnabled && prefersReducedMotion && !isEditing;
|
||||
const isKeyboardFocused = keyboardModeEnabled && isFocusedWithin;
|
||||
const isPreview = previewContext != null;
|
||||
const shouldApplySpacing = !shouldGroup && !removeTopSpacing && previewContext !== MessagePreviewContext.LIST_POPOUT;
|
||||
@@ -934,7 +702,12 @@ export const Message: React.FC<MessageProps> = observer((props) => {
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<FocusRing data-flx="channel.message.focus-ring">
|
||||
<FocusRing
|
||||
enabled={keyboardNavigationEnabled ? keyboardModeEnabled : undefined}
|
||||
within={keyboardNavigationEnabled}
|
||||
offset={keyboardNavigationEnabled ? -2 : undefined}
|
||||
data-flx="channel.message.focus-ring"
|
||||
>
|
||||
<div
|
||||
role="article"
|
||||
aria-label={messageAriaLabel}
|
||||
@@ -961,6 +734,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
|
||||
data-flx-compact={messageDisplayCompact ? 'true' : undefined}
|
||||
data-flx-grouped={shouldGroup && shouldApplyGroupedLayout(message, prevMessage) ? 'true' : undefined}
|
||||
data-flx-action-bar={shouldShowActionBar ? 'true' : undefined}
|
||||
data-flx-action-bar-active={shouldShowActionBar && isActionBarActive ? 'true' : undefined}
|
||||
data-flx-action-bar-forced={shouldShowActionBar && isActionBarForcedVisible ? 'true' : undefined}
|
||||
tabIndex={keyboardModeEnabled ? -1 : undefined}
|
||||
className={messageClasses}
|
||||
@@ -996,7 +770,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
|
||||
}}
|
||||
developerMode={false}
|
||||
isActive={isActionBarActive}
|
||||
onPopoutToggle={handleMessagePopoutToggle}
|
||||
onPopoutToggle={handlePopoutToggle}
|
||||
data-flx="channel.message.message-action-bar-core"
|
||||
/>
|
||||
) : (
|
||||
@@ -1005,7 +779,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
|
||||
handleDelete={handleDelete}
|
||||
sourceChannel={channel}
|
||||
isActive={isActionBarActive}
|
||||
onPopoutToggle={handleMessagePopoutToggle}
|
||||
onPopoutToggle={handlePopoutToggle}
|
||||
data-flx="channel.message.message-action-bar"
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
type MessageFocusCandidate,
|
||||
resolveBottommostFocusableMessageId,
|
||||
} from '@app/features/channel/components/ChannelMessageFocusTarget';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function candidate(messageId: string, top: number, height: number): MessageFocusCandidate {
|
||||
return {messageId, top, bottom: top + height, height};
|
||||
}
|
||||
|
||||
const VIEWPORT_TOP = 0;
|
||||
const VIEWPORT_BOTTOM = 600;
|
||||
|
||||
describe('resolveBottommostFocusableMessageId', () => {
|
||||
it('picks the bottom-most mostly visible message', () => {
|
||||
const result = resolveBottommostFocusableMessageId(
|
||||
[candidate('a', 10, 100), candidate('b', 150, 100), candidate('c', 300, 100)],
|
||||
VIEWPORT_TOP,
|
||||
VIEWPORT_BOTTOM,
|
||||
);
|
||||
expect(result).toBe('c');
|
||||
});
|
||||
|
||||
it('skips a message that is mostly scrolled past the bottom edge', () => {
|
||||
const result = resolveBottommostFocusableMessageId(
|
||||
[candidate('a', 10, 100), candidate('b', 150, 100), candidate('c', 550, 100)],
|
||||
VIEWPORT_TOP,
|
||||
VIEWPORT_BOTTOM,
|
||||
);
|
||||
expect(result).toBe('b');
|
||||
});
|
||||
|
||||
it('falls back to the most visible message when nothing clears the visibility threshold', () => {
|
||||
const result = resolveBottommostFocusableMessageId(
|
||||
[candidate('a', -900, 1000), candidate('b', 200, 1000)],
|
||||
VIEWPORT_TOP,
|
||||
VIEWPORT_BOTTOM,
|
||||
);
|
||||
expect(result).toBe('b');
|
||||
});
|
||||
|
||||
it('prefers the most visible message over the last one in the DOM below the viewport', () => {
|
||||
const result = resolveBottommostFocusableMessageId(
|
||||
[candidate('tall', -100, 1000), candidate('offscreen', 2000, 50)],
|
||||
VIEWPORT_TOP,
|
||||
VIEWPORT_BOTTOM,
|
||||
);
|
||||
expect(result).toBe('tall');
|
||||
});
|
||||
|
||||
it('breaks a tie on visible height by taking the bottom-most message', () => {
|
||||
const result = resolveBottommostFocusableMessageId(
|
||||
[candidate('a', -800, 900), candidate('b', 500, 900)],
|
||||
VIEWPORT_TOP,
|
||||
VIEWPORT_BOTTOM,
|
||||
);
|
||||
expect(result).toBe('b');
|
||||
});
|
||||
|
||||
it('falls back to the last message when nothing overlaps the viewport at all', () => {
|
||||
const result = resolveBottommostFocusableMessageId(
|
||||
[candidate('a', 900, 100), candidate('b', 1200, 100)],
|
||||
VIEWPORT_TOP,
|
||||
VIEWPORT_BOTTOM,
|
||||
);
|
||||
expect(result).toBe('b');
|
||||
});
|
||||
|
||||
it('falls back to the last message when the newest one is hidden behind the composer', () => {
|
||||
const result = resolveBottommostFocusableMessageId([candidate('only', 400, 400)], VIEWPORT_TOP, VIEWPORT_BOTTOM);
|
||||
expect(result).toBe('only');
|
||||
});
|
||||
|
||||
it('ignores zero-height rows when scoring visibility', () => {
|
||||
expect(
|
||||
resolveBottommostFocusableMessageId(
|
||||
[candidate('a', 10, 100), candidate('b', 200, 0)],
|
||||
VIEWPORT_TOP,
|
||||
VIEWPORT_BOTTOM,
|
||||
),
|
||||
).toBe('a');
|
||||
});
|
||||
|
||||
it('returns null when there are no candidates', () => {
|
||||
expect(resolveBottommostFocusableMessageId([], VIEWPORT_TOP, VIEWPORT_BOTTOM)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import type {ChannelMessages} from '@app/features/messaging/state/ChannelMessages';
|
||||
import {type ChannelStreamItem, ChannelStreamType} from '@app/features/messaging/utils/MessageGroupingUtils';
|
||||
import {CHANNEL_MESSAGE_ID_PREFIX} from '@app/features/messaging/utils/MessageNodeSelectors';
|
||||
import {IS_DEV} from '@app/features/platform/types/Env';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import type {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
|
||||
@@ -126,7 +127,7 @@ export function renderChannelStream(props: RenderChannelStreamProps): Array<Reac
|
||||
flashKey={pendingFlashKey}
|
||||
showUnreadDividerSlots={true}
|
||||
unreadDividerBeforeMessageId={unreadDividerBeforeMessageId}
|
||||
idPrefix="chat-messages"
|
||||
idPrefix={CHANNEL_MESSAGE_ID_PREFIX}
|
||||
messageRowClassName={messageRowClassName}
|
||||
messageActionsClassName={messageActionsClassName}
|
||||
renderMessageActions={renderMessageActions}
|
||||
|
||||
@@ -33,12 +33,14 @@ import {
|
||||
} from '@app/features/messaging/state/ChannelMessagesLoadStateMachine';
|
||||
import MessageEdit from '@app/features/messaging/state/MessageEdit';
|
||||
import MessageFocus from '@app/features/messaging/state/MessageFocus';
|
||||
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
|
||||
import MessagesState from '@app/features/messaging/state/MessagingMessages';
|
||||
import {
|
||||
type ChannelStreamItem,
|
||||
createChannelStream,
|
||||
getCollapsedMessageGroupKey,
|
||||
} from '@app/features/messaging/utils/MessageGroupingUtils';
|
||||
import {getMessageSelector} from '@app/features/messaging/utils/MessageNodeSelectors';
|
||||
import LocalUserSpamOverride from '@app/features/moderation/state/LocalUserSpamOverride';
|
||||
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
@@ -398,7 +400,9 @@ export const Messages = observer(function Messages({
|
||||
const scroller = scrollManager.ref.current?.getViewportElement();
|
||||
const innerElement = scrollerInnerRef.current;
|
||||
if (!scroller || !innerElement) return;
|
||||
const messageElements = innerElement.querySelectorAll<HTMLElement>('[data-message-id]');
|
||||
const messageElements = innerElement.querySelectorAll<HTMLElement>(
|
||||
MessageKeyboardFocusRollout.enabled ? getMessageSelector(channel.id) : '[data-message-id]',
|
||||
);
|
||||
if (!messageElements.length) return;
|
||||
const scrollerRect = scroller.getBoundingClientRect();
|
||||
const candidates: Array<MessageFocusCandidate> = [];
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
} from '@app/features/messaging/state/MentionConfirmationStateMachine';
|
||||
import MessageEdit from '@app/features/messaging/state/MessageEdit';
|
||||
import MessageEditMobile from '@app/features/messaging/state/MessageEditMobile';
|
||||
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
|
||||
import MessageReply from '@app/features/messaging/state/MessageReply';
|
||||
import Messages from '@app/features/messaging/state/MessagingMessages';
|
||||
import {CloudUpload} from '@app/features/messaging/upload/CloudUpload';
|
||||
@@ -885,15 +886,17 @@ export const LexicalChannelTextareaContent = observer(
|
||||
onSubmit();
|
||||
}, [canSubmit, channel, hasAttachments, onSubmit]);
|
||||
const handleArrowUpEmpty = useCallback(() => {
|
||||
const claimsArrowUp = MessageKeyboardFocusRollout.enabled;
|
||||
if (KeyboardMode.keyboardModeEnabled) {
|
||||
ComponentBus.dispatch('FOCUS_BOTTOMMOST_MESSAGE', {channelId: channel.id});
|
||||
return;
|
||||
return claimsArrowUp;
|
||||
}
|
||||
const message = Messages.getLastEditableMessage(channel.id);
|
||||
if (!message) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
MessageCommands.startEdit(channel.id, message.id, message.content);
|
||||
return claimsArrowUp;
|
||||
}, [channel.id]);
|
||||
useTextareaDraftAndTyping({
|
||||
channelId: channel.id,
|
||||
|
||||
@@ -11,28 +11,50 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global([data-flx-action-bar]:hover) .actionBarContainer,
|
||||
:global([data-flx-action-bar]) .actionBarContainer:has(:focus-visible) {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
:global(html:not(.experiment-message-hover-tracking) [data-flx-action-bar]:hover) .actionBarContainer {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
:global([data-flx-action-bar]:not([data-flx-action-bar-forced]):hover) .actionBarContainer {
|
||||
:global(html:not(.experiment-message-hover-tracking) [data-flx-action-bar]:not([data-flx-action-bar-forced]):hover)
|
||||
.actionBarContainer {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
:global([data-flx-action-bar][data-flx-action-bar-forced]) .actionBarContainer,
|
||||
.actionBarContainer.actionBarPinned {
|
||||
:global(html:not(.experiment-message-hover-tracking) [data-flx-action-bar][data-flx-action-bar-forced])
|
||||
.actionBarContainer,
|
||||
:global(html:not(.experiment-message-hover-tracking)) .actionBarContainer.actionBarPinned {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
:global(html.experiment-message-hover-tracking [data-flx-action-bar][data-flx-action-bar-active]) .actionBarContainer {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
:global(html.experiment-message-hover-tracking [data-flx-action-bar]:not([data-flx-action-bar-forced]))
|
||||
.actionBarContainer:not(:has(:focus-visible)) {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html:not(.window-focused):not(.unfocused-fully-interactive)) .actionBarContainer,
|
||||
:global(.window-focus-activation-guard) .actionBarContainer {
|
||||
opacity: 0 !important;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8');
|
||||
}
|
||||
|
||||
const messageCss = readSource('../../theme/styles/Message.module.css');
|
||||
const actionBarCss = readSource('./MessageActionBar.module.css');
|
||||
const focusRingCss = readSource('../../ui/focus_ring/FocusRing.module.css');
|
||||
const channelMessageSource = readSource('./ChannelMessage.tsx');
|
||||
const messageFocusRing = channelMessageSource.match(/<FocusRing\b[^>]*>/)?.[0] ?? '';
|
||||
|
||||
describe('message focus ring contract', () => {
|
||||
it('never draws a ring from a bare :focus selector on a message row', () => {
|
||||
expect(messageCss).not.toMatch(/\.message(Compact)?:focus(?!-visible)/);
|
||||
});
|
||||
|
||||
it('routes the row ring through the FocusRing framework', () => {
|
||||
expect(channelMessageSource).toMatch(/from '@app\/features\/ui\/focus_ring\/FocusRing'/);
|
||||
expect(messageFocusRing).not.toBe('');
|
||||
});
|
||||
|
||||
it('reads the ring arm from the keyboard navigation rollout', () => {
|
||||
expect(channelMessageSource).toMatch(/const keyboardNavigationEnabled = MessageKeyboardFocusRollout\.enabled;/);
|
||||
});
|
||||
|
||||
it('only enables the ring in keyboard navigation mode in the experiment arm', () => {
|
||||
expect(messageFocusRing).toMatch(/enabled=\{keyboardNavigationEnabled \? keyboardModeEnabled : undefined\}/);
|
||||
expect(messageFocusRing).toMatch(/within=\{keyboardNavigationEnabled\}/);
|
||||
});
|
||||
|
||||
it('insets the ring inside the row in the experiment arm and keeps the default geometry in control', () => {
|
||||
expect(focusRingCss).toMatch(/pointer-events:\s*none/);
|
||||
expect(messageFocusRing).toMatch(/offset=\{keyboardNavigationEnabled \? -2 : undefined\}/);
|
||||
});
|
||||
|
||||
it('stacks the ring below the action bar', () => {
|
||||
expect(actionBarCss).toMatch(/z-index:\s*var\(--z-index-elevated-1\)/);
|
||||
});
|
||||
|
||||
it('falls back to a system outline under forced colors', () => {
|
||||
const forcedColors = focusRingCss.match(/@media \(forced-colors: active\) \{\n\t\.focusRing \{([^{}]*)\}/)?.[1];
|
||||
expect(forcedColors).toBeDefined();
|
||||
expect(forcedColors).toMatch(/box-shadow:\s*none/);
|
||||
expect(forcedColors).toMatch(/outline-color:\s*Highlight/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
registerMessageHoverTarget,
|
||||
resolveMessageHoverTargetsNow,
|
||||
} from '@app/features/channel/components/MessageHoverTracking';
|
||||
import MessageHoverTrackingRollout from '@app/features/channel/state/MessageHoverTrackingRollout';
|
||||
import {subscribeWindowFocus} from '@app/features/platform/utils/WindowFocusBroadcast';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
|
||||
|
||||
const isPointInsideMessageTree = (messageElement: HTMLElement, point: {x: number; y: number}): boolean => {
|
||||
const target = messageElement.ownerDocument.elementFromPoint(point.x, point.y);
|
||||
return Boolean(target && messageElement.contains(target));
|
||||
};
|
||||
const HOVER_SCROLL_IDLE_MS = 150;
|
||||
let lastPointerPosition: {x: number; y: number} | null = null;
|
||||
let pointerPositionNotificationFrame: number | null = null;
|
||||
let pointerPositionSubscriptionCount = 0;
|
||||
let hoverInvalidationSubscriptionCount = 0;
|
||||
let pointerHoverSuspendedByScroll = false;
|
||||
let pointerHoverScrollIdleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const pointerPositionListeners = new Set<() => void>();
|
||||
const hoverInvalidationListeners = new Set<() => void>();
|
||||
const notifyPointerPositionListeners = (): void => {
|
||||
for (const listener of Array.from(pointerPositionListeners)) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
const notifyHoverInvalidationListeners = (): void => {
|
||||
for (const listener of Array.from(hoverInvalidationListeners)) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
const clearPointerHoverScrollIdleTimer = (): void => {
|
||||
if (pointerHoverScrollIdleTimer == null) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pointerHoverScrollIdleTimer);
|
||||
pointerHoverScrollIdleTimer = null;
|
||||
};
|
||||
const resumePointerHoverAfterScroll = (): void => {
|
||||
clearPointerHoverScrollIdleTimer();
|
||||
if (!pointerHoverSuspendedByScroll) {
|
||||
return;
|
||||
}
|
||||
pointerHoverSuspendedByScroll = false;
|
||||
notifyHoverInvalidationListeners();
|
||||
};
|
||||
const suspendPointerHoverForScroll = (): void => {
|
||||
clearPointerHoverScrollIdleTimer();
|
||||
pointerHoverScrollIdleTimer = setTimeout(resumePointerHoverAfterScroll, HOVER_SCROLL_IDLE_MS);
|
||||
if (pointerHoverSuspendedByScroll) {
|
||||
return;
|
||||
}
|
||||
pointerHoverSuspendedByScroll = true;
|
||||
if (pointerPositionNotificationFrame != null) {
|
||||
cancelAnimationFrame(pointerPositionNotificationFrame);
|
||||
pointerPositionNotificationFrame = null;
|
||||
}
|
||||
notifyHoverInvalidationListeners();
|
||||
};
|
||||
const schedulePointerPositionNotification = (): void => {
|
||||
if (pointerPositionNotificationFrame != null) {
|
||||
return;
|
||||
}
|
||||
pointerPositionNotificationFrame = requestAnimationFrame(() => {
|
||||
pointerPositionNotificationFrame = null;
|
||||
notifyPointerPositionListeners();
|
||||
});
|
||||
};
|
||||
const updateLastPointerPosition = (event: PointerEvent | MouseEvent): void => {
|
||||
if (lastPointerPosition?.x === event.clientX && lastPointerPosition.y === event.clientY) {
|
||||
return;
|
||||
}
|
||||
lastPointerPosition = {x: event.clientX, y: event.clientY};
|
||||
resumePointerHoverAfterScroll();
|
||||
schedulePointerPositionNotification();
|
||||
};
|
||||
const clearLastPointerPosition = (): void => {
|
||||
if (!lastPointerPosition) {
|
||||
return;
|
||||
}
|
||||
lastPointerPosition = null;
|
||||
schedulePointerPositionNotification();
|
||||
};
|
||||
const clearLastPointerPositionOnWindowBlur = (): void => {
|
||||
clearLastPointerPosition();
|
||||
notifyHoverInvalidationListeners();
|
||||
};
|
||||
const clearLastPointerPositionOnWindowExit = (event: PointerEvent | MouseEvent): void => {
|
||||
if (event.relatedTarget == null) {
|
||||
clearLastPointerPosition();
|
||||
}
|
||||
};
|
||||
const supportsPointerPositionEvents = (): boolean => 'PointerEvent' in window;
|
||||
const subscribePointerPosition = (listener: () => void): (() => void) => {
|
||||
if (pointerPositionSubscriptionCount === 0) {
|
||||
if (supportsPointerPositionEvents()) {
|
||||
window.addEventListener('pointermove', updateLastPointerPosition, true);
|
||||
window.addEventListener('pointerdown', updateLastPointerPosition, true);
|
||||
window.addEventListener('pointerout', clearLastPointerPositionOnWindowExit, true);
|
||||
} else {
|
||||
window.addEventListener('mousemove', updateLastPointerPosition, true);
|
||||
window.addEventListener('mousedown', updateLastPointerPosition, true);
|
||||
window.addEventListener('mouseout', clearLastPointerPositionOnWindowExit, true);
|
||||
}
|
||||
}
|
||||
pointerPositionSubscriptionCount += 1;
|
||||
pointerPositionListeners.add(listener);
|
||||
return () => {
|
||||
pointerPositionListeners.delete(listener);
|
||||
pointerPositionSubscriptionCount = Math.max(0, pointerPositionSubscriptionCount - 1);
|
||||
if (pointerPositionSubscriptionCount !== 0) {
|
||||
return;
|
||||
}
|
||||
lastPointerPosition = null;
|
||||
if (pointerPositionNotificationFrame != null) {
|
||||
cancelAnimationFrame(pointerPositionNotificationFrame);
|
||||
pointerPositionNotificationFrame = null;
|
||||
}
|
||||
if (supportsPointerPositionEvents()) {
|
||||
window.removeEventListener('pointermove', updateLastPointerPosition, true);
|
||||
window.removeEventListener('pointerdown', updateLastPointerPosition, true);
|
||||
window.removeEventListener('pointerout', clearLastPointerPositionOnWindowExit, true);
|
||||
} else {
|
||||
window.removeEventListener('mousemove', updateLastPointerPosition, true);
|
||||
window.removeEventListener('mousedown', updateLastPointerPosition, true);
|
||||
window.removeEventListener('mouseout', clearLastPointerPositionOnWindowExit, true);
|
||||
}
|
||||
};
|
||||
};
|
||||
const subscribeMessageHoverInvalidation = (listener: () => void): (() => void) => {
|
||||
if (hoverInvalidationSubscriptionCount === 0) {
|
||||
window.addEventListener('scroll', suspendPointerHoverForScroll, true);
|
||||
window.addEventListener('resize', notifyHoverInvalidationListeners);
|
||||
window.addEventListener('blur', clearLastPointerPositionOnWindowBlur);
|
||||
}
|
||||
hoverInvalidationSubscriptionCount += 1;
|
||||
hoverInvalidationListeners.add(listener);
|
||||
return () => {
|
||||
hoverInvalidationListeners.delete(listener);
|
||||
hoverInvalidationSubscriptionCount = Math.max(0, hoverInvalidationSubscriptionCount - 1);
|
||||
if (hoverInvalidationSubscriptionCount !== 0) {
|
||||
return;
|
||||
}
|
||||
clearPointerHoverScrollIdleTimer();
|
||||
pointerHoverSuspendedByScroll = false;
|
||||
window.removeEventListener('scroll', suspendPointerHoverForScroll, true);
|
||||
window.removeEventListener('resize', notifyHoverInvalidationListeners);
|
||||
window.removeEventListener('blur', clearLastPointerPositionOnWindowBlur);
|
||||
};
|
||||
};
|
||||
|
||||
interface UseMessageHoverStateParams {
|
||||
messageRef: React.RefObject<HTMLDivElement | null>;
|
||||
mobileLayoutEnabled: boolean;
|
||||
keyboardModeEnabled: boolean;
|
||||
contextMenuOpen: boolean;
|
||||
}
|
||||
|
||||
export interface MessageHoverState {
|
||||
isHovering: boolean;
|
||||
isPopoutOpen: boolean;
|
||||
handlePopoutToggle: (isOpen: boolean) => void;
|
||||
trackingEnabled: boolean;
|
||||
}
|
||||
|
||||
export function useMessageHoverState({
|
||||
messageRef,
|
||||
mobileLayoutEnabled,
|
||||
keyboardModeEnabled,
|
||||
contextMenuOpen,
|
||||
}: UseMessageHoverStateParams): MessageHoverState {
|
||||
const trackingEnabled = MessageHoverTrackingRollout.enabled;
|
||||
const [isHoveringDesktop, setIsHoveringDesktop] = useState(false);
|
||||
const [isPopoutOpen, setIsPopoutOpen] = useState(false);
|
||||
const isHoveringDesktopRef = useRef(false);
|
||||
const popoutCloseRafRef = useRef<number | null>(null);
|
||||
const setDesktopHoverState = useCallback((isHovered: boolean) => {
|
||||
if (isHoveringDesktopRef.current === isHovered) {
|
||||
return;
|
||||
}
|
||||
isHoveringDesktopRef.current = isHovered;
|
||||
setIsHoveringDesktop(isHovered);
|
||||
}, []);
|
||||
const isPointerInsideMessage = useCallback((): boolean => {
|
||||
if (mobileLayoutEnabled) {
|
||||
return false;
|
||||
}
|
||||
if (pointerHoverSuspendedByScroll) {
|
||||
return false;
|
||||
}
|
||||
const element = messageRef.current;
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
if (!lastPointerPosition) {
|
||||
return element.matches(':hover');
|
||||
}
|
||||
return isPointInsideMessageTree(element, lastPointerPosition);
|
||||
}, [mobileLayoutEnabled, messageRef]);
|
||||
const syncPointerHoverState = useCallback((): boolean => {
|
||||
const isHovered = isPointerInsideMessage();
|
||||
setDesktopHoverState(isHovered);
|
||||
return isHovered;
|
||||
}, [isPointerInsideMessage, setDesktopHoverState]);
|
||||
const cancelScheduledPopoutClose = useCallback(() => {
|
||||
if (popoutCloseRafRef.current == null) {
|
||||
return;
|
||||
}
|
||||
cancelAnimationFrame(popoutCloseRafRef.current);
|
||||
popoutCloseRafRef.current = null;
|
||||
}, []);
|
||||
const handlePopoutToggle = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
if (trackingEnabled) {
|
||||
setIsPopoutOpen(isOpen);
|
||||
return;
|
||||
}
|
||||
if (isOpen) {
|
||||
cancelScheduledPopoutClose();
|
||||
setIsPopoutOpen(true);
|
||||
return;
|
||||
}
|
||||
cancelScheduledPopoutClose();
|
||||
popoutCloseRafRef.current = requestAnimationFrame(() => {
|
||||
popoutCloseRafRef.current = null;
|
||||
syncPointerHoverState();
|
||||
setIsPopoutOpen(false);
|
||||
});
|
||||
},
|
||||
[cancelScheduledPopoutClose, syncPointerHoverState, trackingEnabled],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!trackingEnabled || mobileLayoutEnabled) {
|
||||
return;
|
||||
}
|
||||
const element = messageRef.current;
|
||||
if (element == null) {
|
||||
return;
|
||||
}
|
||||
return registerMessageHoverTarget(element, setDesktopHoverState);
|
||||
}, [trackingEnabled, mobileLayoutEnabled, messageRef, setDesktopHoverState]);
|
||||
useEffect(() => {
|
||||
if (trackingEnabled || mobileLayoutEnabled || !messageRef.current) return;
|
||||
const element = messageRef.current;
|
||||
const handleMouseEnter = (event: MouseEvent) => {
|
||||
updateLastPointerPosition(event);
|
||||
if (pointerHoverSuspendedByScroll) {
|
||||
return;
|
||||
}
|
||||
setDesktopHoverState(true);
|
||||
};
|
||||
const handleMouseLeave = (event: MouseEvent) => {
|
||||
updateLastPointerPosition(event);
|
||||
setDesktopHoverState(false);
|
||||
};
|
||||
element.addEventListener('mouseenter', handleMouseEnter);
|
||||
element.addEventListener('mouseleave', handleMouseLeave);
|
||||
const unsubscribeFocus = subscribeWindowFocus(syncPointerHoverState);
|
||||
const unsubscribeHoverInvalidation = subscribeMessageHoverInvalidation(syncPointerHoverState);
|
||||
const rafId = requestAnimationFrame(syncPointerHoverState);
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
element.removeEventListener('mouseenter', handleMouseEnter);
|
||||
element.removeEventListener('mouseleave', handleMouseLeave);
|
||||
unsubscribeFocus();
|
||||
unsubscribeHoverInvalidation();
|
||||
};
|
||||
}, [
|
||||
trackingEnabled,
|
||||
mobileLayoutEnabled,
|
||||
keyboardModeEnabled,
|
||||
messageRef,
|
||||
setDesktopHoverState,
|
||||
syncPointerHoverState,
|
||||
]);
|
||||
const shouldTrackActivePointer =
|
||||
!trackingEnabled && !mobileLayoutEnabled && (isHoveringDesktop || isPopoutOpen || contextMenuOpen);
|
||||
useEffect(() => {
|
||||
if (!shouldTrackActivePointer) {
|
||||
return;
|
||||
}
|
||||
const rafId = requestAnimationFrame(syncPointerHoverState);
|
||||
const unsubscribePointerPosition = subscribePointerPosition(syncPointerHoverState);
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
unsubscribePointerPosition();
|
||||
};
|
||||
}, [shouldTrackActivePointer, syncPointerHoverState]);
|
||||
const isOverlayOpen = trackingEnabled ? contextMenuOpen || isPopoutOpen : contextMenuOpen;
|
||||
const wasOverlayOpenRef = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
const wasOpen = wasOverlayOpenRef.current;
|
||||
wasOverlayOpenRef.current = isOverlayOpen;
|
||||
if (!wasOpen || isOverlayOpen) {
|
||||
return;
|
||||
}
|
||||
if (trackingEnabled) {
|
||||
resolveMessageHoverTargetsNow();
|
||||
return;
|
||||
}
|
||||
syncPointerHoverState();
|
||||
}, [isOverlayOpen, syncPointerHoverState, trackingEnabled]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelScheduledPopoutClose();
|
||||
};
|
||||
}, [cancelScheduledPopoutClose]);
|
||||
return {
|
||||
isHovering: mobileLayoutEnabled ? false : isHoveringDesktop,
|
||||
isPopoutOpen,
|
||||
handlePopoutToggle,
|
||||
trackingEnabled,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
interface CssRule {
|
||||
readonly selector: string;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8');
|
||||
}
|
||||
|
||||
function parseCssRules(css: string): Array<CssRule> {
|
||||
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const rules: Array<CssRule> = [];
|
||||
for (const match of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
rules.push({selector: match[1].replace(/\s+/g, ''), body: match[2].trim()});
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
const EXPERIMENT_CLASS = 'experiment-message-hover-tracking';
|
||||
const TREATMENT_GATE = `html.${EXPERIMENT_CLASS}`;
|
||||
const CONTROL_GATE = `:not(.${EXPERIMENT_CLASS})`;
|
||||
|
||||
const actionBarCss = readSource('./MessageActionBar.module.css');
|
||||
const messageCss = readSource('../../theme/styles/Message.module.css');
|
||||
const actionBarSource = readSource('./MessageActionBar.tsx');
|
||||
const channelMessageSource = readSource('./ChannelMessage.tsx');
|
||||
const hoverStateSource = readSource('./MessageHoverState.ts');
|
||||
const rolloutSource = readSource('../state/MessageHoverTrackingRollout.ts');
|
||||
const appSource = readSource('../../../app/App.tsx');
|
||||
|
||||
const actionBarContainerRules = parseCssRules(actionBarCss).filter((rule) =>
|
||||
rule.selector.includes('.actionBarContainer'),
|
||||
);
|
||||
const revealingRules = actionBarContainerRules.filter((rule) => /visibility:\s*visible/.test(rule.body));
|
||||
const treatmentRevealRules = revealingRules.filter((rule) => rule.selector.includes(TREATMENT_GATE));
|
||||
const controlRevealRules = revealingRules.filter((rule) => rule.selector.includes(CONTROL_GATE));
|
||||
|
||||
describe('message hover style contract', () => {
|
||||
it('reveals the action bar from the tracked hover state in the experiment arm', () => {
|
||||
expect(treatmentRevealRules.length).toBeGreaterThan(0);
|
||||
for (const rule of treatmentRevealRules) {
|
||||
expect(rule.selector).not.toMatch(/:hover/);
|
||||
expect(rule.selector).toMatch(/data-flx-action-bar-active/);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the legacy :hover reveal behind the control arm', () => {
|
||||
expect(controlRevealRules.length).toBeGreaterThan(0);
|
||||
expect(controlRevealRules.some((rule) => rule.selector.includes(':hover'))).toBe(true);
|
||||
for (const rule of controlRevealRules) {
|
||||
expect(rule.selector).not.toMatch(/data-flx-action-bar-active/);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every action bar reveal rule exactly one arm', () => {
|
||||
for (const rule of revealingRules) {
|
||||
if (rule.selector.includes(':focus-visible')) continue;
|
||||
const treatment = rule.selector.includes(TREATMENT_GATE);
|
||||
const control = rule.selector.includes(CONTROL_GATE);
|
||||
expect(treatment || control).toBe(true);
|
||||
expect(treatment && control).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('never reveals the action bar from the message stylesheet in the experiment arm', () => {
|
||||
const buttonRevealRules = parseCssRules(messageCss).filter(
|
||||
(rule) => /\.buttons\b/.test(rule.selector) && /opacity:\s*1/.test(rule.body),
|
||||
);
|
||||
expect(buttonRevealRules.length).toBeGreaterThan(0);
|
||||
for (const rule of buttonRevealRules) {
|
||||
expect(rule.selector).toContain(CONTROL_GATE);
|
||||
}
|
||||
});
|
||||
|
||||
it('derives the row highlight and the action bar from the same hover state', () => {
|
||||
const highlightSource = channelMessageSource.match(/(\w+) && !isPreview && styles\.messageHovered/)?.[1];
|
||||
const actionBarSourceState = channelMessageSource.match(/const isActionBarActive = (\w+) \|\|/)?.[1];
|
||||
expect(highlightSource).toBeDefined();
|
||||
expect(actionBarSourceState).toBe(highlightSource);
|
||||
expect(channelMessageSource).toMatch(
|
||||
/data-flx-action-bar-active=\{shouldShowActionBar && isActionBarActive \? 'true' : undefined\}/,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the tracked hover state without the retained :hover chain', () => {
|
||||
expect(readSource('./MessageHoverTracking.ts')).not.toMatch(/matches\(':hover'\)/);
|
||||
const legacyOracle = hoverStateSource.match(/const isPointerInsideMessage[\s\S]*?\n\t\};/)?.[0];
|
||||
expect(legacyOracle).toBeDefined();
|
||||
expect(legacyOracle).toMatch(/matches\(':hover'\)/);
|
||||
expect(hoverStateSource).toMatch(/if \(!trackingEnabled \|\| mobileLayoutEnabled\) \{/);
|
||||
expect(hoverStateSource).toMatch(
|
||||
/if \(trackingEnabled \|\| mobileLayoutEnabled \|\| !messageRef\.current\) return;/,
|
||||
);
|
||||
});
|
||||
|
||||
it('drives the stylesheet arm from the rollout assignment', () => {
|
||||
expect(rolloutSource).toContain(`'${EXPERIMENT_CLASS}'`);
|
||||
expect(actionBarCss).toContain(EXPERIMENT_CLASS);
|
||||
expect(messageCss).toContain(EXPERIMENT_CLASS);
|
||||
expect(appSource).toMatch(
|
||||
/useDocumentClassToggle\(MESSAGE_HOVER_TRACKING_EXPERIMENT_CLASS, MessageHoverTrackingRollout\.enabled\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the control arm reachable from the action bar component', () => {
|
||||
expect(actionBarSource).toMatch(/messageStyles\.buttons/);
|
||||
expect(actionBarSource).toMatch(/styles\.actionBarPinned/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
refreshMessageHoverTargets,
|
||||
registerMessageHoverTarget,
|
||||
resetMessageHoverTrackingForTests,
|
||||
} from '@app/features/channel/components/MessageHoverTracking';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
let hitTarget: Element | null = null;
|
||||
let elementFromPointSpy: ReturnType<typeof vi.spyOn>;
|
||||
let matchesSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
function nextFrame(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function createRow(): {row: HTMLElement; content: HTMLElement} {
|
||||
const row = document.createElement('div');
|
||||
const content = document.createElement('span');
|
||||
row.append(content);
|
||||
document.body.append(row);
|
||||
return {row, content};
|
||||
}
|
||||
|
||||
function movePointerTo(target: Element | null, x = 10, y = 10): void {
|
||||
hitTarget = target;
|
||||
window.dispatchEvent(new MouseEvent('pointermove', {clientX: x, clientY: y, bubbles: true}));
|
||||
}
|
||||
|
||||
function scrollTo(target: Element | null): void {
|
||||
hitTarget = target;
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hitTarget = null;
|
||||
elementFromPointSpy = vi.spyOn(document, 'elementFromPoint').mockImplementation(() => hitTarget);
|
||||
matchesSpy = vi.spyOn(Element.prototype, 'matches');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
resetMessageHoverTrackingForTests();
|
||||
elementFromPointSpy.mockRestore();
|
||||
matchesSpy.mockRestore();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe('MessageHoverTracking', () => {
|
||||
it('arms the row that owns the element under the pointer', async () => {
|
||||
const {row, content} = createRow();
|
||||
const listener = vi.fn();
|
||||
registerMessageHoverTarget(row, listener);
|
||||
await nextFrame();
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
movePointerTo(content);
|
||||
await nextFrame();
|
||||
|
||||
expect(listener).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('keeps at most one row armed when the pointer moves between adjacent rows', async () => {
|
||||
const first = createRow();
|
||||
const second = createRow();
|
||||
const firstListener = vi.fn();
|
||||
const secondListener = vi.fn();
|
||||
registerMessageHoverTarget(first.row, firstListener);
|
||||
registerMessageHoverTarget(second.row, secondListener);
|
||||
|
||||
movePointerTo(first.content);
|
||||
await nextFrame();
|
||||
expect(firstListener).toHaveBeenLastCalledWith(true);
|
||||
expect(secondListener).not.toHaveBeenCalled();
|
||||
|
||||
movePointerTo(second.content);
|
||||
await nextFrame();
|
||||
expect(firstListener).toHaveBeenLastCalledWith(false);
|
||||
expect(secondListener).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('does not re-arm a row on window refocus when the pointer left during the blur', async () => {
|
||||
const {row, content} = createRow();
|
||||
const listener = vi.fn();
|
||||
registerMessageHoverTarget(row, listener);
|
||||
|
||||
movePointerTo(content);
|
||||
await nextFrame();
|
||||
expect(listener).toHaveBeenLastCalledWith(true);
|
||||
|
||||
window.dispatchEvent(new Event('blur'));
|
||||
expect(listener).toHaveBeenLastCalledWith(false);
|
||||
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
refreshMessageHoverTargets();
|
||||
await nextFrame();
|
||||
await nextFrame();
|
||||
|
||||
expect(listener).toHaveBeenLastCalledWith(false);
|
||||
expect(listener.mock.calls.filter(([isHovered]) => isHovered === true)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('never consults the retained :hover chain as a hover oracle', async () => {
|
||||
const {row, content} = createRow();
|
||||
registerMessageHoverTarget(row, vi.fn());
|
||||
|
||||
movePointerTo(content);
|
||||
await nextFrame();
|
||||
window.dispatchEvent(new Event('blur'));
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
refreshMessageHoverTargets();
|
||||
await nextFrame();
|
||||
|
||||
expect(matchesSpy.mock.calls.flat()).not.toContain(':hover');
|
||||
});
|
||||
|
||||
it('disarms and does not re-arm while rows scroll under a stationary pointer', async () => {
|
||||
const first = createRow();
|
||||
const second = createRow();
|
||||
const firstListener = vi.fn();
|
||||
const secondListener = vi.fn();
|
||||
registerMessageHoverTarget(first.row, firstListener);
|
||||
registerMessageHoverTarget(second.row, secondListener);
|
||||
|
||||
movePointerTo(first.content);
|
||||
await nextFrame();
|
||||
expect(firstListener).toHaveBeenLastCalledWith(true);
|
||||
|
||||
scrollTo(second.content);
|
||||
await nextFrame();
|
||||
|
||||
expect(firstListener).toHaveBeenLastCalledWith(false);
|
||||
expect(secondListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('arms the row under the pointer once scrolling settles', async () => {
|
||||
vi.useFakeTimers();
|
||||
const first = createRow();
|
||||
const second = createRow();
|
||||
const firstListener = vi.fn();
|
||||
const secondListener = vi.fn();
|
||||
registerMessageHoverTarget(first.row, firstListener);
|
||||
registerMessageHoverTarget(second.row, secondListener);
|
||||
|
||||
hitTarget = first.content;
|
||||
window.dispatchEvent(new MouseEvent('pointermove', {clientX: 10, clientY: 10, bubbles: true}));
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
expect(firstListener).toHaveBeenLastCalledWith(true);
|
||||
|
||||
scrollTo(second.content);
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
expect(secondListener).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(secondListener).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('stays suspended for as long as momentum keeps firing scroll events', async () => {
|
||||
vi.useFakeTimers();
|
||||
const {row, content} = createRow();
|
||||
const listener = vi.fn();
|
||||
registerMessageHoverTarget(row, listener);
|
||||
|
||||
hitTarget = content;
|
||||
window.dispatchEvent(new MouseEvent('pointermove', {clientX: 10, clientY: 10, bubbles: true}));
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
expect(listener).toHaveBeenLastCalledWith(true);
|
||||
|
||||
scrollTo(content);
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
expect(listener).toHaveBeenLastCalledWith(false);
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
scrollTo(content);
|
||||
}
|
||||
expect(listener).toHaveBeenLastCalledWith(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(listener).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('re-arms immediately when the pointer actually moves during a scroll', async () => {
|
||||
const {row, content} = createRow();
|
||||
const listener = vi.fn();
|
||||
registerMessageHoverTarget(row, listener);
|
||||
|
||||
scrollTo(content);
|
||||
await nextFrame();
|
||||
expect(listener).not.toHaveBeenCalledWith(true);
|
||||
|
||||
movePointerTo(content);
|
||||
await nextFrame();
|
||||
expect(listener).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('does not let a scroll-driven pointerover re-arm a row', async () => {
|
||||
const {row, content} = createRow();
|
||||
const listener = vi.fn();
|
||||
registerMessageHoverTarget(row, listener);
|
||||
|
||||
scrollTo(content);
|
||||
await nextFrame();
|
||||
|
||||
hitTarget = content;
|
||||
window.dispatchEvent(new MouseEvent('pointerover', {clientX: 10, clientY: 10, bubbles: true}));
|
||||
await nextFrame();
|
||||
|
||||
expect(listener).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('re-arms the row underneath after an overlay that covered it is dismissed', async () => {
|
||||
const {row, content} = createRow();
|
||||
const overlay = document.createElement('div');
|
||||
document.body.append(overlay);
|
||||
const listener = vi.fn();
|
||||
registerMessageHoverTarget(row, listener);
|
||||
|
||||
movePointerTo(content);
|
||||
await nextFrame();
|
||||
expect(listener).toHaveBeenLastCalledWith(true);
|
||||
|
||||
hitTarget = overlay;
|
||||
refreshMessageHoverTargets();
|
||||
await nextFrame();
|
||||
expect(listener).toHaveBeenLastCalledWith(false);
|
||||
|
||||
overlay.remove();
|
||||
hitTarget = content;
|
||||
refreshMessageHoverTargets();
|
||||
await nextFrame();
|
||||
|
||||
expect(listener).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('does not treat a touch contact as hover', async () => {
|
||||
const {row, content} = createRow();
|
||||
const listener = vi.fn();
|
||||
registerMessageHoverTarget(row, listener);
|
||||
|
||||
hitTarget = content;
|
||||
window.dispatchEvent(new PointerEvent('pointerdown', {clientX: 10, clientY: 10, pointerType: 'touch'}));
|
||||
await nextFrame();
|
||||
|
||||
expect(listener).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('disarms when the pointer leaves the window', async () => {
|
||||
const {row, content} = createRow();
|
||||
const listener = vi.fn();
|
||||
registerMessageHoverTarget(row, listener);
|
||||
|
||||
movePointerTo(content);
|
||||
await nextFrame();
|
||||
expect(listener).toHaveBeenLastCalledWith(true);
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointerout', {relatedTarget: null}));
|
||||
|
||||
expect(listener).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('releases ownership when an armed row unregisters', async () => {
|
||||
const {row, content} = createRow();
|
||||
const listener = vi.fn();
|
||||
const unregister = registerMessageHoverTarget(row, listener);
|
||||
const other = createRow();
|
||||
const otherListener = vi.fn();
|
||||
registerMessageHoverTarget(other.row, otherListener);
|
||||
|
||||
movePointerTo(content);
|
||||
await nextFrame();
|
||||
expect(listener).toHaveBeenLastCalledWith(true);
|
||||
|
||||
unregister();
|
||||
hitTarget = other.content;
|
||||
await nextFrame();
|
||||
|
||||
expect(otherListener).toHaveBeenLastCalledWith(true);
|
||||
expect(listener).not.toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('detaches every global listener once the last row unregisters', () => {
|
||||
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
|
||||
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
|
||||
const {row} = createRow();
|
||||
|
||||
const unregister = registerMessageHoverTarget(row, vi.fn());
|
||||
const added = addEventListenerSpy.mock.calls.map(([type, listener]) => ({type, listener}));
|
||||
unregister();
|
||||
const removed = removeEventListenerSpy.mock.calls.map(([type, listener]) => ({type, listener}));
|
||||
|
||||
expect(added.length).toBeGreaterThan(0);
|
||||
for (const addedListener of added) {
|
||||
expect(
|
||||
removed.some(
|
||||
(removedListener) =>
|
||||
removedListener.type === addedListener.type && removedListener.listener === addedListener.listener,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
addEventListenerSpy.mockRestore();
|
||||
removeEventListenerSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
type MessageHoverListener = (isHovered: boolean) => void;
|
||||
|
||||
const SCROLL_IDLE_MS = 150;
|
||||
|
||||
const hoverTargets = new Map<HTMLElement, MessageHoverListener>();
|
||||
let pointerPosition: {x: number; y: number} | null = null;
|
||||
let hoveredTarget: HTMLElement | null = null;
|
||||
let resolveFrame: number | null = null;
|
||||
let globalListenersAttached = false;
|
||||
let scrollIdleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let suspendedByScroll = false;
|
||||
|
||||
const resolveHoveredTarget = (): HTMLElement | null => {
|
||||
if (pointerPosition == null) {
|
||||
return null;
|
||||
}
|
||||
let node: Element | null = document.elementFromPoint(pointerPosition.x, pointerPosition.y);
|
||||
while (node != null) {
|
||||
if (node instanceof HTMLElement && hoverTargets.has(node)) {
|
||||
return node;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const setHoveredTarget = (nextHoveredTarget: HTMLElement | null): void => {
|
||||
if (hoveredTarget === nextHoveredTarget) {
|
||||
return;
|
||||
}
|
||||
const previousHoveredTarget = hoveredTarget;
|
||||
hoveredTarget = nextHoveredTarget;
|
||||
if (previousHoveredTarget != null) {
|
||||
hoverTargets.get(previousHoveredTarget)?.(false);
|
||||
}
|
||||
if (nextHoveredTarget != null) {
|
||||
hoverTargets.get(nextHoveredTarget)?.(true);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelScheduledResolve = (): void => {
|
||||
if (resolveFrame == null) {
|
||||
return;
|
||||
}
|
||||
cancelAnimationFrame(resolveFrame);
|
||||
resolveFrame = null;
|
||||
};
|
||||
|
||||
const scheduleResolve = (): void => {
|
||||
if (suspendedByScroll || resolveFrame != null) {
|
||||
return;
|
||||
}
|
||||
resolveFrame = requestAnimationFrame(() => {
|
||||
resolveFrame = null;
|
||||
setHoveredTarget(resolveHoveredTarget());
|
||||
});
|
||||
};
|
||||
|
||||
const clearScrollIdleTimer = (): void => {
|
||||
if (scrollIdleTimer == null) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(scrollIdleTimer);
|
||||
scrollIdleTimer = null;
|
||||
};
|
||||
|
||||
const resumeAfterScroll = (): void => {
|
||||
clearScrollIdleTimer();
|
||||
if (!suspendedByScroll) {
|
||||
return;
|
||||
}
|
||||
suspendedByScroll = false;
|
||||
scheduleResolve();
|
||||
};
|
||||
|
||||
const forgetPointerPosition = (): void => {
|
||||
pointerPosition = null;
|
||||
cancelScheduledResolve();
|
||||
clearScrollIdleTimer();
|
||||
suspendedByScroll = false;
|
||||
setHoveredTarget(null);
|
||||
};
|
||||
|
||||
const isTouchPointerEvent = (event: PointerEvent | MouseEvent): boolean =>
|
||||
'pointerType' in event && event.pointerType === 'touch';
|
||||
|
||||
const handlePointerActivity = (event: PointerEvent | MouseEvent): void => {
|
||||
if (isTouchPointerEvent(event)) {
|
||||
return;
|
||||
}
|
||||
pointerPosition = {x: event.clientX, y: event.clientY};
|
||||
scheduleResolve();
|
||||
};
|
||||
|
||||
const handlePointerWindowExit = (event: PointerEvent | MouseEvent): void => {
|
||||
if (isTouchPointerEvent(event) || event.relatedTarget != null) {
|
||||
return;
|
||||
}
|
||||
forgetPointerPosition();
|
||||
};
|
||||
|
||||
const handlePointerMotion = (event: PointerEvent | MouseEvent): void => {
|
||||
if (isTouchPointerEvent(event)) {
|
||||
return;
|
||||
}
|
||||
pointerPosition = {x: event.clientX, y: event.clientY};
|
||||
if (suspendedByScroll) {
|
||||
resumeAfterScroll();
|
||||
return;
|
||||
}
|
||||
scheduleResolve();
|
||||
};
|
||||
|
||||
const handleScroll = (): void => {
|
||||
suspendedByScroll = true;
|
||||
cancelScheduledResolve();
|
||||
setHoveredTarget(null);
|
||||
clearScrollIdleTimer();
|
||||
scrollIdleTimer = setTimeout(resumeAfterScroll, SCROLL_IDLE_MS);
|
||||
};
|
||||
|
||||
const handleLayoutChange = (): void => {
|
||||
scheduleResolve();
|
||||
};
|
||||
|
||||
const supportsPointerEvents = (): boolean => 'PointerEvent' in window;
|
||||
|
||||
const attachGlobalListeners = (): void => {
|
||||
if (globalListenersAttached) {
|
||||
return;
|
||||
}
|
||||
globalListenersAttached = true;
|
||||
if (supportsPointerEvents()) {
|
||||
window.addEventListener('pointermove', handlePointerMotion, true);
|
||||
window.addEventListener('pointerdown', handlePointerMotion, true);
|
||||
window.addEventListener('pointerover', handlePointerActivity, true);
|
||||
window.addEventListener('pointerout', handlePointerWindowExit, true);
|
||||
} else {
|
||||
window.addEventListener('mousemove', handlePointerMotion, true);
|
||||
window.addEventListener('mousedown', handlePointerMotion, true);
|
||||
window.addEventListener('mouseover', handlePointerActivity, true);
|
||||
window.addEventListener('mouseout', handlePointerWindowExit, true);
|
||||
}
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
window.addEventListener('resize', handleLayoutChange);
|
||||
window.addEventListener('blur', forgetPointerPosition);
|
||||
};
|
||||
|
||||
const detachGlobalListeners = (): void => {
|
||||
if (!globalListenersAttached) {
|
||||
return;
|
||||
}
|
||||
globalListenersAttached = false;
|
||||
clearScrollIdleTimer();
|
||||
suspendedByScroll = false;
|
||||
if (supportsPointerEvents()) {
|
||||
window.removeEventListener('pointermove', handlePointerMotion, true);
|
||||
window.removeEventListener('pointerdown', handlePointerMotion, true);
|
||||
window.removeEventListener('pointerover', handlePointerActivity, true);
|
||||
window.removeEventListener('pointerout', handlePointerWindowExit, true);
|
||||
} else {
|
||||
window.removeEventListener('mousemove', handlePointerMotion, true);
|
||||
window.removeEventListener('mousedown', handlePointerMotion, true);
|
||||
window.removeEventListener('mouseover', handlePointerActivity, true);
|
||||
window.removeEventListener('mouseout', handlePointerWindowExit, true);
|
||||
}
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
window.removeEventListener('resize', handleLayoutChange);
|
||||
window.removeEventListener('blur', forgetPointerPosition);
|
||||
};
|
||||
|
||||
export function registerMessageHoverTarget(element: HTMLElement, listener: MessageHoverListener): () => void {
|
||||
hoverTargets.set(element, listener);
|
||||
attachGlobalListeners();
|
||||
scheduleResolve();
|
||||
return () => {
|
||||
if (hoverTargets.get(element) !== listener) {
|
||||
return;
|
||||
}
|
||||
hoverTargets.delete(element);
|
||||
if (hoveredTarget === element) {
|
||||
hoveredTarget = null;
|
||||
listener(false);
|
||||
}
|
||||
if (hoverTargets.size > 0) {
|
||||
scheduleResolve();
|
||||
return;
|
||||
}
|
||||
detachGlobalListeners();
|
||||
cancelScheduledResolve();
|
||||
hoveredTarget = null;
|
||||
};
|
||||
}
|
||||
|
||||
export function refreshMessageHoverTargets(): void {
|
||||
scheduleResolve();
|
||||
}
|
||||
|
||||
export function resolveMessageHoverTargetsNow(): void {
|
||||
if (suspendedByScroll) {
|
||||
return;
|
||||
}
|
||||
cancelScheduledResolve();
|
||||
setHoveredTarget(resolveHoveredTarget());
|
||||
}
|
||||
|
||||
export function resetMessageHoverTrackingForTests(): void {
|
||||
detachGlobalListeners();
|
||||
cancelScheduledResolve();
|
||||
clearScrollIdleTimer();
|
||||
suspendedByScroll = false;
|
||||
hoverTargets.clear();
|
||||
pointerPosition = null;
|
||||
hoveredTarget = null;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {parse} from '@app/features/messaging/components/markdown/renderers';
|
||||
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
|
||||
import MessageEdit from '@app/features/messaging/state/MessageEdit';
|
||||
import {hasStyleableMessageText} from '@app/features/messaging/utils/FailedMessageDisplayUtils';
|
||||
import {buildMessageContentCopyText} from '@app/features/messaging/utils/MessageCopyTextUtils';
|
||||
import {
|
||||
buildExistingAttachmentEditReferences,
|
||||
canSubmitEmptyMessageEdit,
|
||||
@@ -140,6 +141,16 @@ export const UserMessage = observer(() => {
|
||||
}),
|
||||
[message.id, message.channelId, message.mentionChannels],
|
||||
);
|
||||
const contentCopyText = useMemo(
|
||||
() =>
|
||||
buildMessageContentCopyText(astNodes, {
|
||||
channelId: message.channelId,
|
||||
messageId: message.id,
|
||||
mentionChannels: message.mentionChannels,
|
||||
i18n,
|
||||
}),
|
||||
[astNodes, message.id, message.channelId, message.mentionChannels, i18n.locale],
|
||||
);
|
||||
const shouldHideContent =
|
||||
UserSettings.getRenderEmbeds() &&
|
||||
message.embeds.length > 0 &&
|
||||
@@ -281,7 +292,7 @@ export const UserMessage = observer(() => {
|
||||
className={clsx(markupStyles.markup)}
|
||||
data-search-highlight-scope="message"
|
||||
data-flx="channel.user-message.render-message-content.div"
|
||||
{...messageContentCopyBlockProps(message.content)}
|
||||
{...messageContentCopyBlockProps(contentCopyText)}
|
||||
>
|
||||
<SafeMarkdown
|
||||
content={message.content}
|
||||
@@ -315,6 +326,7 @@ export const UserMessage = observer(() => {
|
||||
shouldShowEditingInput,
|
||||
shouldHideContent,
|
||||
markdownOptions,
|
||||
contentCopyText,
|
||||
message,
|
||||
message.content,
|
||||
message.id,
|
||||
@@ -415,7 +427,7 @@ export const UserMessage = observer(() => {
|
||||
className={clsx(markupStyles.markup)}
|
||||
data-search-highlight-scope="message"
|
||||
data-flx="channel.user-message.div"
|
||||
{...messageContentCopyBlockProps(message.content)}
|
||||
{...messageContentCopyBlockProps(contentCopyText)}
|
||||
>
|
||||
<SafeMarkdown
|
||||
content={message.content}
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle';
|
||||
import {act, useRef} from 'react';
|
||||
import {createRoot, type Root} from 'react-dom/client';
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const {canFocusTextareaMock} = vi.hoisted(() => ({canFocusTextareaMock: vi.fn(() => true)}));
|
||||
|
||||
vi.mock('@app/features/platform/utils/InputFocusManager', () => ({
|
||||
canFocusTextarea: canFocusTextareaMock,
|
||||
}));
|
||||
|
||||
const {useChannelComposerDraftFocusRestore} = await import(
|
||||
'@app/features/channel/components/useChannelComposerDraftFocusRestore'
|
||||
);
|
||||
|
||||
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let focusSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
interface HarnessProps {
|
||||
initialDraft: string;
|
||||
textareaInputDisabled?: boolean;
|
||||
inlineEditActive?: boolean;
|
||||
}
|
||||
|
||||
function Harness({initialDraft, textareaInputDisabled = false, inlineEditActive = false}: HarnessProps) {
|
||||
const handleRef = useRef<ComposerHandle | null>(null);
|
||||
handleRef.current = {focus: focusSpy} as unknown as ComposerHandle;
|
||||
const editableRef = useRef<HTMLDivElement | null>(null);
|
||||
useChannelComposerDraftFocusRestore({
|
||||
handleRef,
|
||||
editableRef,
|
||||
initialDraft,
|
||||
textareaInputDisabled,
|
||||
inlineEditActive,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={editableRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
data-flx="channel.use-channel-composer-draft-focus-restore-test.harness.div"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function render(props: HarnessProps): void {
|
||||
act(() => {
|
||||
root.render(<Harness data-flx="channel.use-channel-composer-draft-focus-restore-test.harness" {...props} />);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
focusSpy = vi.fn();
|
||||
canFocusTextareaMock.mockReturnValue(true);
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
canFocusTextareaMock.mockReset();
|
||||
});
|
||||
|
||||
describe('useChannelComposerDraftFocusRestore', () => {
|
||||
test('focuses the composer when the channel is entered with a pending draft', () => {
|
||||
render({initialDraft: 'half written'});
|
||||
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('leaves focus alone when there is no pending draft', () => {
|
||||
render({initialDraft: ''});
|
||||
expect(focusSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('yields to an active inline message edit', () => {
|
||||
render({initialDraft: 'half written', inlineEditActive: true});
|
||||
expect(focusSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does nothing when composer input is disabled', () => {
|
||||
render({initialDraft: 'half written', textareaInputDisabled: true});
|
||||
expect(focusSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('respects the shared focus guard that blocks mobile, modals and popouts', () => {
|
||||
canFocusTextareaMock.mockReturnValue(false);
|
||||
render({initialDraft: 'half written'});
|
||||
expect(focusSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('restores focus only on the channel mount, not on every re-render', () => {
|
||||
render({initialDraft: 'half written'});
|
||||
render({initialDraft: 'half written'});
|
||||
render({initialDraft: 'half written more'});
|
||||
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
type BlockedMessageGroupsAssignmentResponse,
|
||||
INERT_BLOCKED_MESSAGE_GROUPS_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/experiment/BlockedMessageGroupsSchemas';
|
||||
import {
|
||||
type ExperimentAssignmentsResponse,
|
||||
INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
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 {BlockedMessageGroupsRollout} = await import('@app/features/channel/state/BlockedMessageGroupsRollout');
|
||||
|
||||
function publish(assignment: BlockedMessageGroupsAssignmentResponse): void {
|
||||
const response: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: 300,
|
||||
poll_jitter_percent: 15,
|
||||
assignments: {blocked_message_groups: assignment},
|
||||
};
|
||||
runInAction(() => {
|
||||
ExperimentAssignments.response = response;
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
runInAction(() => {
|
||||
ExperimentAssignments.response = INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE;
|
||||
});
|
||||
});
|
||||
|
||||
describe('BlockedMessageGroupsRollout', () => {
|
||||
it('reads the inert assignment out of the inert envelope', () => {
|
||||
expect(BlockedMessageGroupsRollout.assignment).toBe(INERT_BLOCKED_MESSAGE_GROUPS_ASSIGNMENT);
|
||||
expect(BlockedMessageGroupsRollout.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('stays on the control arm while the rollout is disabled', () => {
|
||||
publish({enabled: false, config_version: 2, user_targeted: false, source: null});
|
||||
expect(BlockedMessageGroupsRollout.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('stays on the control arm for an account the rollout did not target', () => {
|
||||
publish({enabled: true, config_version: 2, user_targeted: false, source: null});
|
||||
expect(BlockedMessageGroupsRollout.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('moves to the experiment arm for a targeted account', () => {
|
||||
publish({enabled: true, config_version: 2, user_targeted: true, source: 'canary'});
|
||||
expect(BlockedMessageGroupsRollout.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('follows the envelope back to the control arm when the store is reset', () => {
|
||||
publish({enabled: true, config_version: 2, user_targeted: true, source: 'user_rule'});
|
||||
expect(BlockedMessageGroupsRollout.enabled).toBe(true);
|
||||
ExperimentAssignments.reset();
|
||||
expect(BlockedMessageGroupsRollout.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
|
||||
import type {BlockedMessageGroupsAssignmentResponse} from '@fluxer/schema/src/domains/experiment/BlockedMessageGroupsSchemas';
|
||||
import {readBlockedMessageGroupsAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
|
||||
export const BLOCKED_MESSAGE_GROUPS_EXPERIMENT_CLASS = 'experiment-blocked-message-groups';
|
||||
|
||||
class BlockedMessageGroupsRolloutSelector {
|
||||
get assignment(): BlockedMessageGroupsAssignmentResponse {
|
||||
return readBlockedMessageGroupsAssignment(ExperimentAssignments.response);
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
const assignment = this.assignment;
|
||||
return assignment.enabled && assignment.user_targeted;
|
||||
}
|
||||
}
|
||||
|
||||
export const BlockedMessageGroupsRollout = new BlockedMessageGroupsRolloutSelector();
|
||||
|
||||
export default BlockedMessageGroupsRollout;
|
||||
@@ -0,0 +1,75 @@
|
||||
// @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 {
|
||||
INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT,
|
||||
type MessageHoverTrackingAssignmentResponse,
|
||||
} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas';
|
||||
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 {MessageHoverTrackingRollout} = await import('@app/features/channel/state/MessageHoverTrackingRollout');
|
||||
|
||||
function publish(assignment: MessageHoverTrackingAssignmentResponse): void {
|
||||
const response: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: 300,
|
||||
poll_jitter_percent: 15,
|
||||
assignments: {message_hover_tracking: assignment},
|
||||
};
|
||||
runInAction(() => {
|
||||
ExperimentAssignments.response = response;
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
runInAction(() => {
|
||||
ExperimentAssignments.response = INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE;
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageHoverTrackingRollout', () => {
|
||||
it('reads the inert assignment out of the inert envelope', () => {
|
||||
expect(MessageHoverTrackingRollout.assignment).toBe(INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT);
|
||||
expect(MessageHoverTrackingRollout.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('stays on the control arm while the rollout is disabled', () => {
|
||||
publish({enabled: false, config_version: 4, user_targeted: false, source: null});
|
||||
expect(MessageHoverTrackingRollout.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('stays on the control arm for an account the rollout did not target', () => {
|
||||
publish({enabled: true, config_version: 4, user_targeted: false, source: null});
|
||||
expect(MessageHoverTrackingRollout.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('moves to the experiment arm for a targeted account', () => {
|
||||
publish({enabled: true, config_version: 4, user_targeted: true, source: 'canary'});
|
||||
expect(MessageHoverTrackingRollout.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('follows the envelope back to the control arm when the store is reset', () => {
|
||||
publish({enabled: true, config_version: 4, user_targeted: true, source: 'user_rule'});
|
||||
expect(MessageHoverTrackingRollout.enabled).toBe(true);
|
||||
ExperimentAssignments.reset();
|
||||
expect(MessageHoverTrackingRollout.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
|
||||
import {readMessageHoverTrackingAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import type {MessageHoverTrackingAssignmentResponse} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas';
|
||||
|
||||
export const MESSAGE_HOVER_TRACKING_EXPERIMENT_CLASS = 'experiment-message-hover-tracking';
|
||||
|
||||
class MessageHoverTrackingRolloutSelector {
|
||||
get assignment(): MessageHoverTrackingAssignmentResponse {
|
||||
return readMessageHoverTrackingAssignment(ExperimentAssignments.response);
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
const assignment = this.assignment;
|
||||
return assignment.enabled && assignment.user_targeted;
|
||||
}
|
||||
}
|
||||
|
||||
export const MessageHoverTrackingRollout = new MessageHoverTrackingRolloutSelector();
|
||||
|
||||
export default MessageHoverTrackingRollout;
|
||||
@@ -160,7 +160,7 @@ export interface LexicalComposerInputProps {
|
||||
onChange: (display: string, segments: Array<MentionSegment>, wire: string) => void;
|
||||
onCursorMove: () => void;
|
||||
onEnter?: () => void;
|
||||
onArrowUp: () => void;
|
||||
onArrowUp: () => boolean;
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLElement>) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
@@ -608,8 +608,9 @@ const ComposerInner = ({
|
||||
return false;
|
||||
}
|
||||
if (event != null && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
|
||||
if ($isComposerEmpty()) {
|
||||
cb.current.onArrowUp();
|
||||
if ($isComposerEmpty() && cb.current.onArrowUp()) {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -58,7 +58,7 @@ export interface LexicalRichInputProps {
|
||||
i18n: I18n;
|
||||
}
|
||||
|
||||
const NOOP = (): void => {};
|
||||
const ARROW_UP_UNHANDLED = (): boolean => false;
|
||||
const SAFE_CHANNEL_TRIGGERS: Array<TriggerType> = ['emoji', 'mention', 'channel'];
|
||||
const SAFE_CONTEXT_FREE_TRIGGERS: Array<TriggerType> = ['emoji'];
|
||||
|
||||
@@ -245,7 +245,7 @@ export const LexicalRichInput = ({
|
||||
onChange={emitChange}
|
||||
onCursorMove={onCursorMove}
|
||||
onEnter={onSubmit == null ? undefined : handleEnter}
|
||||
onArrowUp={NOOP}
|
||||
onArrowUp={ARROW_UP_UNHANDLED}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
|
||||
+1
-1
@@ -244,7 +244,7 @@ function renderTableRow(
|
||||
export function TableRenderer({node, id, renderChildren, options}: RendererProps<TableNode>): React.ReactElement {
|
||||
const copyText = renderTableNodeToMarkdown(node, {
|
||||
channelId: options.channelId,
|
||||
preserveMarkdown: true,
|
||||
preserveMarkdown: false,
|
||||
includeEmojiNames: true,
|
||||
i18n: options.i18n,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {CHANNEL_MESSAGE_ID_PREFIX, findMessageElement} from '@app/features/messaging/utils/MessageNodeSelectors';
|
||||
import {act, createElement, type RefObject} from 'react';
|
||||
import {createRoot, type Root} from 'react-dom/client';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const keyboardModeMock = {keyboardModeEnabled: true};
|
||||
const messageFocusMock = {focusedMessageId: null as string | null};
|
||||
const rolloutMock = {enabled: true};
|
||||
|
||||
vi.mock('@app/features/ui/state/KeyboardMode', () => ({default: keyboardModeMock}));
|
||||
vi.mock('@app/features/messaging/state/MessageFocus', () => ({default: messageFocusMock}));
|
||||
vi.mock('@app/features/messaging/state/MessageKeyboardFocusRollout', () => ({default: rolloutMock}));
|
||||
|
||||
const {useMessageListKeyboardNavigation} = await import(
|
||||
'@app/features/messaging/hooks/useMessageListKeyboardNavigation'
|
||||
);
|
||||
|
||||
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const CHANNEL_ID = '900000000000000001';
|
||||
|
||||
interface RowSpec {
|
||||
messageId: string;
|
||||
idPrefix: string;
|
||||
}
|
||||
|
||||
let viewport: HTMLElement;
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function mountRows(specs: ReadonlyArray<RowSpec>): void {
|
||||
for (const spec of specs) {
|
||||
const row = document.createElement('div');
|
||||
row.id = `${spec.idPrefix}-${CHANNEL_ID}-${spec.messageId}`;
|
||||
row.dataset.messageId = spec.messageId;
|
||||
row.dataset.channelId = CHANNEL_ID;
|
||||
row.tabIndex = -1;
|
||||
viewport.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
function focusedRowId(): string | null {
|
||||
const active = document.activeElement;
|
||||
return active instanceof HTMLElement ? (active.dataset.messageId ?? null) : null;
|
||||
}
|
||||
|
||||
function render(onFocusMessage?: (messageId: string) => void): void {
|
||||
const containerRef: RefObject<HTMLElement | null> = {current: viewport};
|
||||
function Harness(): null {
|
||||
useMessageListKeyboardNavigation({containerRef, channelId: CHANNEL_ID, onFocusMessage, allowWhenInactive: true});
|
||||
return null;
|
||||
}
|
||||
act(() => {
|
||||
root.render(createElement(Harness));
|
||||
});
|
||||
}
|
||||
|
||||
function pressArrow(key: 'ArrowUp' | 'ArrowDown'): void {
|
||||
act(() => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', {key, bubbles: true, cancelable: true}));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
keyboardModeMock.keyboardModeEnabled = true;
|
||||
messageFocusMock.focusedMessageId = null;
|
||||
rolloutMock.enabled = true;
|
||||
viewport = document.createElement('div');
|
||||
document.body.append(viewport);
|
||||
viewport.addEventListener('focusin', (event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLElement && target.dataset.messageId) {
|
||||
messageFocusMock.focusedMessageId = target.dataset.messageId;
|
||||
}
|
||||
});
|
||||
host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('useMessageListKeyboardNavigation', () => {
|
||||
it('walks from the newest message up through a revealed blocked group', () => {
|
||||
mountRows([
|
||||
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
{messageId: '2', idPrefix: 'blocked-messages'},
|
||||
{messageId: '3', idPrefix: 'blocked-messages'},
|
||||
{messageId: '4', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
]);
|
||||
render((messageId) => {
|
||||
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
|
||||
});
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBe('4');
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBe('3');
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBe('2');
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBe('1');
|
||||
});
|
||||
|
||||
it('walks back down out of a revealed blocked group', () => {
|
||||
mountRows([
|
||||
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
{messageId: '2', idPrefix: 'blocked-messages'},
|
||||
{messageId: '3', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
]);
|
||||
render((messageId) => {
|
||||
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
|
||||
});
|
||||
|
||||
pressArrow('ArrowDown');
|
||||
expect(focusedRowId()).toBe('1');
|
||||
pressArrow('ArrowDown');
|
||||
expect(focusedRowId()).toBe('2');
|
||||
pressArrow('ArrowDown');
|
||||
expect(focusedRowId()).toBe('3');
|
||||
});
|
||||
|
||||
it('keeps navigating when the focus delegate cannot resolve the target element', () => {
|
||||
mountRows([
|
||||
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
{messageId: '2', idPrefix: 'blocked-messages'},
|
||||
{messageId: '3', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
]);
|
||||
const onFocusMessage = vi.fn<(messageId: string) => void>();
|
||||
render(onFocusMessage);
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(onFocusMessage).toHaveBeenLastCalledWith('3');
|
||||
expect(focusedRowId()).toBe('3');
|
||||
pressArrow('ArrowUp');
|
||||
expect(onFocusMessage).toHaveBeenLastCalledWith('2');
|
||||
expect(focusedRowId()).toBe('2');
|
||||
});
|
||||
|
||||
it('leaves scrolling to the delegate when the delegate did move focus', () => {
|
||||
mountRows([{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}]);
|
||||
const scrollIntoView = vi.fn();
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
|
||||
render((messageId) => {
|
||||
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
|
||||
});
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBe('1');
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scrolls the target into view itself when the delegate did nothing', () => {
|
||||
mountRows([{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}]);
|
||||
const scrollIntoView = vi.fn();
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
|
||||
render(vi.fn());
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(scrollIntoView).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps navigating while a checkbox inside the list holds focus', () => {
|
||||
mountRows([
|
||||
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
{messageId: '2', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
]);
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
viewport.append(checkbox);
|
||||
render((messageId) => {
|
||||
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
|
||||
});
|
||||
checkbox.focus();
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBe('2');
|
||||
});
|
||||
|
||||
it('stops navigating while a text input inside the list holds focus', () => {
|
||||
mountRows([
|
||||
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
{messageId: '2', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
]);
|
||||
const textInput = document.createElement('input');
|
||||
textInput.type = 'text';
|
||||
viewport.append(textInput);
|
||||
render((messageId) => {
|
||||
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
|
||||
});
|
||||
textInput.focus();
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBeNull();
|
||||
});
|
||||
|
||||
it('skips messages that a collapsed group has removed from the DOM', () => {
|
||||
mountRows([
|
||||
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
{messageId: '4', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
]);
|
||||
render((messageId) => {
|
||||
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
|
||||
});
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBe('4');
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMessageListKeyboardNavigation control arm', () => {
|
||||
beforeEach(() => {
|
||||
rolloutMock.enabled = false;
|
||||
});
|
||||
|
||||
it('stops at a row the focus delegate cannot resolve', () => {
|
||||
mountRows([
|
||||
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
{messageId: '2', idPrefix: 'blocked-messages'},
|
||||
]);
|
||||
const onFocusMessage = vi.fn<(messageId: string) => void>();
|
||||
render(onFocusMessage);
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(onFocusMessage).toHaveBeenLastCalledWith('2');
|
||||
expect(focusedRowId()).toBeNull();
|
||||
});
|
||||
|
||||
it('never scrolls the target itself when a delegate is supplied', () => {
|
||||
mountRows([{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}]);
|
||||
const scrollIntoView = vi.fn();
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
|
||||
render(vi.fn());
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats a focused checkbox as editable and stops navigating', () => {
|
||||
mountRows([
|
||||
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
{messageId: '2', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
|
||||
]);
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
viewport.append(checkbox);
|
||||
render((messageId) => {
|
||||
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
|
||||
});
|
||||
checkbox.focus();
|
||||
|
||||
pressArrow('ArrowUp');
|
||||
expect(focusedRowId()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {isEditableElement} from '@app/features/app/keybindings/utils/EditableElement';
|
||||
import MessageFocus from '@app/features/messaging/state/MessageFocus';
|
||||
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
|
||||
import {getMessageSelector} from '@app/features/messaging/utils/MessageNodeSelectors';
|
||||
import type {ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import {type RefObject, useEffect} from 'react';
|
||||
@@ -52,17 +55,6 @@ const EMPTY_MESSAGE_NODES_SNAPSHOT: MessageNodesSnapshot = {
|
||||
selector: '',
|
||||
ts: 0,
|
||||
};
|
||||
const escapeSelectorValue = (value: string): string => {
|
||||
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
||||
return CSS.escape(value);
|
||||
}
|
||||
return value.replace(/\\/gu, '\\\\').replace(/"/gu, '\\"');
|
||||
};
|
||||
const getMessageSelector = (channelId?: string, messageId?: string): string => {
|
||||
const channelSelector = channelId ? `[data-channel-id="${escapeSelectorValue(channelId)}"]` : '[data-channel-id]';
|
||||
const messageSelector = messageId ? `[data-message-id="${escapeSelectorValue(messageId)}"]` : '[data-message-id]';
|
||||
return `${channelSelector}${messageSelector}`;
|
||||
};
|
||||
|
||||
export function useMessageListKeyboardNavigation(options: MessageListKeyboardNavigationOptions): void {
|
||||
const {
|
||||
@@ -78,6 +70,7 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
|
||||
allowWhenInactive = false,
|
||||
} = options;
|
||||
const keyboardModeEnabled = KeyboardMode.keyboardModeEnabled;
|
||||
const keyboardNavigationEnabled = MessageKeyboardFocusRollout.enabled;
|
||||
useEffect(() => {
|
||||
if (!keyboardModeEnabled) return;
|
||||
let messageNodesCache: MessageNodesSnapshot = EMPTY_MESSAGE_NODES_SNAPSHOT;
|
||||
@@ -140,10 +133,19 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
|
||||
};
|
||||
return messageNodesCache;
|
||||
};
|
||||
const hasFocusInside = (node: HTMLElement): boolean => {
|
||||
const activeElement = node.ownerDocument?.activeElement ?? document.activeElement;
|
||||
return activeElement != null && (activeElement === node || node.contains(activeElement));
|
||||
};
|
||||
const focusNode = (node: HTMLElement, messageId: string) => {
|
||||
if (onFocusMessage) {
|
||||
onFocusMessage(messageId);
|
||||
return;
|
||||
if (!keyboardNavigationEnabled || hasFocusInside(node)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (keyboardNavigationEnabled && node.tabIndex < 0) {
|
||||
node.tabIndex = -1;
|
||||
}
|
||||
node.focus({preventScroll: true});
|
||||
node.scrollIntoView({block: 'nearest', inline: 'nearest'});
|
||||
@@ -197,7 +199,10 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!keyboardModeEnabled) return;
|
||||
if (isEditableTarget(document.activeElement)) return;
|
||||
const activeElementIsEditable = keyboardNavigationEnabled
|
||||
? isEditableElement(document.activeElement)
|
||||
: isEditableTarget(document.activeElement);
|
||||
if (activeElementIsEditable) return;
|
||||
const delta = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0;
|
||||
const isNavigationKey = delta !== 0;
|
||||
if (isNavigationKey && hasShortcutModifier(event)) return;
|
||||
@@ -226,6 +231,7 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
|
||||
};
|
||||
}, [
|
||||
keyboardModeEnabled,
|
||||
keyboardNavigationEnabled,
|
||||
containerRef,
|
||||
channelId,
|
||||
onFocusMessage,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// @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 {
|
||||
INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT,
|
||||
type MessageKeyboardFocusAssignmentResponse,
|
||||
} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas';
|
||||
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 {MessageKeyboardFocusRollout} = await import('@app/features/messaging/state/MessageKeyboardFocusRollout');
|
||||
|
||||
function publish(assignment: MessageKeyboardFocusAssignmentResponse): void {
|
||||
const response: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: 300,
|
||||
poll_jitter_percent: 15,
|
||||
assignments: {message_keyboard_focus: assignment},
|
||||
};
|
||||
runInAction(() => {
|
||||
ExperimentAssignments.response = response;
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
runInAction(() => {
|
||||
ExperimentAssignments.response = INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE;
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageKeyboardFocusRollout', () => {
|
||||
it('reads the inert assignment out of the inert envelope', () => {
|
||||
expect(MessageKeyboardFocusRollout.assignment).toBe(INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT);
|
||||
expect(MessageKeyboardFocusRollout.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('stays on the control arm while the rollout is disabled', () => {
|
||||
publish({enabled: false, config_version: 4, user_targeted: false, source: null});
|
||||
expect(MessageKeyboardFocusRollout.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('stays on the control arm for an account the rollout did not target', () => {
|
||||
publish({enabled: true, config_version: 4, user_targeted: false, source: null});
|
||||
expect(MessageKeyboardFocusRollout.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('moves to the experiment arm for a targeted account', () => {
|
||||
publish({enabled: true, config_version: 4, user_targeted: true, source: 'canary'});
|
||||
expect(MessageKeyboardFocusRollout.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('follows the envelope back to the control arm when the store is reset', () => {
|
||||
publish({enabled: true, config_version: 4, user_targeted: true, source: 'user_rule'});
|
||||
expect(MessageKeyboardFocusRollout.enabled).toBe(true);
|
||||
ExperimentAssignments.reset();
|
||||
expect(MessageKeyboardFocusRollout.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
|
||||
import {readMessageKeyboardFocusAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import type {MessageKeyboardFocusAssignmentResponse} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas';
|
||||
|
||||
class MessageKeyboardFocusRolloutSelector {
|
||||
get assignment(): MessageKeyboardFocusAssignmentResponse {
|
||||
return readMessageKeyboardFocusAssignment(ExperimentAssignments.response);
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
const assignment = this.assignment;
|
||||
return assignment.enabled && assignment.user_targeted;
|
||||
}
|
||||
}
|
||||
|
||||
export const MessageKeyboardFocusRollout = new MessageKeyboardFocusRolloutSelector();
|
||||
|
||||
export default MessageKeyboardFocusRollout;
|
||||
@@ -3,7 +3,12 @@
|
||||
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import {getParserFlagsForContext} from '@app/features/messaging/utils/markdown/MarkdownParserFlags';
|
||||
import {parseAndRenderToPlaintext} from '@app/features/messaging/utils/markdown/Plaintext';
|
||||
import {
|
||||
type PlaintextRenderOptions,
|
||||
parseAndRenderToPlaintext,
|
||||
renderAstToPlaintext,
|
||||
} from '@app/features/messaging/utils/markdown/Plaintext';
|
||||
import type {Node} from '@app/features/messaging/utils/markdown/parser/Nodes';
|
||||
import * as DateUtils from '@app/features/user/utils/DateFormatting';
|
||||
import {MessageEmbedTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {MessageEmbed} from '@fluxer/schema/src/domains/message/EmbedSchemas';
|
||||
@@ -102,6 +107,17 @@ function buildOmittedEmbedUrls(content?: string | null, renderedContent?: string
|
||||
return urls;
|
||||
}
|
||||
|
||||
function createPlaintextCopyOptions(context: MarkdownCopyContext): PlaintextRenderOptions {
|
||||
return {
|
||||
channelId: context.channelId,
|
||||
preserveMarkdown: false,
|
||||
includeEmojiNames: true,
|
||||
includeLinkUrls: true,
|
||||
mentionChannels: context.mentionChannels,
|
||||
i18n: context.i18n,
|
||||
};
|
||||
}
|
||||
|
||||
function renderMarkdownCopyText(
|
||||
content: string | undefined | null,
|
||||
parserFlags: number,
|
||||
@@ -110,14 +126,11 @@ function renderMarkdownCopyText(
|
||||
if (!content) {
|
||||
return '';
|
||||
}
|
||||
return parseAndRenderToPlaintext(content, parserFlags, {
|
||||
channelId: context.channelId,
|
||||
preserveMarkdown: false,
|
||||
includeEmojiNames: true,
|
||||
includeLinkUrls: true,
|
||||
mentionChannels: context.mentionChannels,
|
||||
i18n: context.i18n,
|
||||
});
|
||||
return parseAndRenderToPlaintext(content, parserFlags, createPlaintextCopyOptions(context));
|
||||
}
|
||||
|
||||
export function buildMessageContentCopyText(nodes: Array<Node>, context: MarkdownCopyContext): string {
|
||||
return normaliseCopyBlock(renderAstToPlaintext(nodes, createPlaintextCopyOptions(context)));
|
||||
}
|
||||
|
||||
function buildAttachmentCopyText(attachment: MessageAttachment): string {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
CHANNEL_MESSAGE_ID_PREFIX,
|
||||
findMessageElement,
|
||||
getMessageSelector,
|
||||
} from '@app/features/messaging/utils/MessageNodeSelectors';
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const CHANNEL_ID = '900000000000000001';
|
||||
|
||||
function messageRow(messageId: string, idPrefix: string): HTMLElement {
|
||||
const row = document.createElement('div');
|
||||
row.id = `${idPrefix}-${CHANNEL_ID}-${messageId}`;
|
||||
row.dataset.messageId = messageId;
|
||||
row.dataset.channelId = CHANNEL_ID;
|
||||
return row;
|
||||
}
|
||||
|
||||
function viewport(): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
document.body.append(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
describe('MessageNodeSelectors', () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it('resolves a normal stream row through the id fast path', () => {
|
||||
const scroller = viewport();
|
||||
const row = messageRow('1', CHANNEL_MESSAGE_ID_PREFIX);
|
||||
scroller.append(row);
|
||||
|
||||
expect(findMessageElement(document, scroller, CHANNEL_ID, '1')).toBe(row);
|
||||
});
|
||||
|
||||
it('resolves a revealed blocked group row that carries a different id prefix', () => {
|
||||
const scroller = viewport();
|
||||
scroller.append(messageRow('1', CHANNEL_MESSAGE_ID_PREFIX));
|
||||
const blockedRow = messageRow('2', 'blocked-messages');
|
||||
scroller.append(blockedRow);
|
||||
|
||||
expect(document.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${CHANNEL_ID}-2`)).toBeNull();
|
||||
expect(findMessageElement(document, scroller, CHANNEL_ID, '2')).toBe(blockedRow);
|
||||
});
|
||||
|
||||
it('resolves a revealed spammer group row', () => {
|
||||
const scroller = viewport();
|
||||
const spammerRow = messageRow('3', 'spammer-messages');
|
||||
scroller.append(spammerRow);
|
||||
|
||||
expect(findMessageElement(document, scroller, CHANNEL_ID, '3')).toBe(spammerRow);
|
||||
});
|
||||
|
||||
it('falls back to the viewport-scoped lookup and ignores rows outside it', () => {
|
||||
const scroller = viewport();
|
||||
const searchPanel = viewport();
|
||||
searchPanel.append(messageRow('4', 'blocked-messages'));
|
||||
|
||||
expect(document.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${CHANNEL_ID}-4`)).toBeNull();
|
||||
expect(findMessageElement(document, scroller, CHANNEL_ID, '4')).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves a row through the document-wide id lookup even when it sits outside the viewport', () => {
|
||||
const scroller = viewport();
|
||||
const popout = viewport();
|
||||
const inPopout = messageRow('8', CHANNEL_MESSAGE_ID_PREFIX);
|
||||
popout.append(inPopout);
|
||||
|
||||
expect(findMessageElement(document, scroller, CHANNEL_ID, '8')).toBe(inPopout);
|
||||
});
|
||||
|
||||
it('prefers the row inside the viewport when neither row carries the canonical id', () => {
|
||||
const scroller = viewport();
|
||||
const inStream = messageRow('5', 'blocked-messages');
|
||||
scroller.append(inStream);
|
||||
const searchPanel = viewport();
|
||||
const inSearch = messageRow('5', 'search-messages');
|
||||
searchPanel.append(inSearch);
|
||||
|
||||
expect(document.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${CHANNEL_ID}-5`)).toBeNull();
|
||||
expect(findMessageElement(document, scroller, CHANNEL_ID, '5')).toBe(inStream);
|
||||
});
|
||||
|
||||
it('does not resolve a row belonging to another channel', () => {
|
||||
const scroller = viewport();
|
||||
const foreign = document.createElement('div');
|
||||
foreign.dataset.messageId = '6';
|
||||
foreign.dataset.channelId = '900000000000000002';
|
||||
scroller.append(foreign);
|
||||
|
||||
expect(findMessageElement(document, scroller, CHANNEL_ID, '6')).toBeNull();
|
||||
});
|
||||
|
||||
it('requires both data attributes so message group row wrappers are not candidates', () => {
|
||||
const scroller = viewport();
|
||||
const groupWrapper = document.createElement('div');
|
||||
groupWrapper.dataset.messageId = '7';
|
||||
const row = messageRow('7', CHANNEL_MESSAGE_ID_PREFIX);
|
||||
groupWrapper.append(row);
|
||||
scroller.append(groupWrapper);
|
||||
|
||||
const matches = scroller.querySelectorAll<HTMLElement>(getMessageSelector(CHANNEL_ID));
|
||||
expect(Array.from(matches)).toEqual([row]);
|
||||
});
|
||||
|
||||
it('leaves snowflake ids untouched instead of emitting identifier escapes', () => {
|
||||
expect(getMessageSelector(CHANNEL_ID, '900000000000000009')).toBe(
|
||||
'[data-channel-id="900000000000000001"][data-message-id="900000000000000009"]',
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes quotes and backslashes so an id can never break out of the attribute selector', () => {
|
||||
expect(getMessageSelector(undefined, 'a"]b')).toBe('[data-channel-id][data-message-id="a\\"]b"]');
|
||||
expect(getMessageSelector(undefined, 'a\\b')).toBe('[data-channel-id][data-message-id="a\\\\b"]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const CHANNEL_MESSAGE_ID_PREFIX = 'chat-messages';
|
||||
|
||||
export const escapeSelectorValue = (value: string): string =>
|
||||
value.replace(/[\\"]/gu, '\\$&').replace(/[\n\r\f]/gu, (char) => `\\${char.charCodeAt(0).toString(16)} `);
|
||||
|
||||
export const getMessageSelector = (channelId?: string, messageId?: string): string => {
|
||||
const channelSelector = channelId ? `[data-channel-id="${escapeSelectorValue(channelId)}"]` : '[data-channel-id]';
|
||||
const messageSelector = messageId ? `[data-message-id="${escapeSelectorValue(messageId)}"]` : '[data-message-id]';
|
||||
return `${channelSelector}${messageSelector}`;
|
||||
};
|
||||
|
||||
export const findMessageElement = (
|
||||
doc: Document | null | undefined,
|
||||
viewport: HTMLElement | null | undefined,
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
): HTMLElement | null => {
|
||||
const byId = doc?.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${channelId}-${messageId}`) ?? null;
|
||||
if (byId != null) {
|
||||
return byId as HTMLElement;
|
||||
}
|
||||
return viewport?.querySelector<HTMLElement>(getMessageSelector(channelId, messageId)) ?? null;
|
||||
};
|
||||
@@ -44,7 +44,7 @@ describe('Message selection copy utils', () => {
|
||||
expect(buildMessageSelectionCopyTextForRange({rootElement: root, selectionRange: range})).toBe(tableCopyText);
|
||||
});
|
||||
it('does not duplicate a block message header when a bot badge is selected with the body', () => {
|
||||
const messageContent = '## App Canary Deployed\n\nVersion: `2026.519.3`\nImage: `2026.519.3`';
|
||||
const messageContent = 'App Canary Deployed\n\nVersion: 2026.519.3\nImage: 2026.519.3';
|
||||
document.body.innerHTML = [
|
||||
'<div data-message-selection-root="true">',
|
||||
'<div data-message-id="message-1" data-is-group-start="true">',
|
||||
|
||||
@@ -4,7 +4,9 @@ import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
||||
import type {ChannelMessages} from '@app/features/messaging/state/ChannelMessages';
|
||||
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
|
||||
import Messages from '@app/features/messaging/state/MessagingMessages';
|
||||
import {CHANNEL_MESSAGE_ID_PREFIX, findMessageElement} from '@app/features/messaging/utils/MessageNodeSelectors';
|
||||
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
|
||||
import Navigation from '@app/features/navigation/state/Navigation';
|
||||
import {evaluateScrollPinning, type ScrollPinResult} from '@app/features/platform/utils/ScrollPosition';
|
||||
@@ -210,10 +212,12 @@ export class ScrollManager {
|
||||
|
||||
layoutGetElementFromMessageId(messageId: string): HTMLElement | null {
|
||||
const doc = this.scrollGetDocument();
|
||||
const {channel} = this.props;
|
||||
if (!doc) return null;
|
||||
const elementId = `chat-messages-${channel.id}-${messageId}`;
|
||||
return doc.getElementById(elementId) as HTMLElement | null;
|
||||
const {channel} = this.props;
|
||||
if (!MessageKeyboardFocusRollout.enabled) {
|
||||
return doc.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${channel.id}-${messageId}`) as HTMLElement | null;
|
||||
}
|
||||
return findMessageElement(doc, this.ref.current?.getViewportElement(), channel.id, messageId);
|
||||
}
|
||||
|
||||
private layoutGetContainerLayout(container: HTMLElement): ContainerLayout {
|
||||
|
||||
@@ -75,18 +75,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.message.messageHovered:not(.messageMentioned):not(.messageReplying):not(.messageHighlight):not(.messagePreview),
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.messageCompact.messageHovered:not(.messageMentioned):not(.messageReplying):not(.messageHighlight):not(
|
||||
.messagePreview
|
||||
) {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.message.messageMentioned.messageHovered,
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.messageCompact.messageMentioned.messageHovered {
|
||||
background-color: var(--message-mention-bg-hover);
|
||||
}
|
||||
@@ -98,13 +114,29 @@
|
||||
background-color: var(--message-mention-bg);
|
||||
}
|
||||
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.message.messageReplying.messageHovered:not(.messageMentioned),
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.message.messageHighlight.messageHovered:not(.messageMentioned),
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.messageCompact.messageReplying.messageHovered:not(.messageMentioned),
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.messageCompact.messageHighlight.messageHovered:not(.messageMentioned) {
|
||||
background-color: var(--message-reply-bg);
|
||||
}
|
||||
@@ -127,9 +159,17 @@
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.message:hover,
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.messageCompact:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
@@ -578,7 +618,11 @@
|
||||
transform: translateX(0.5rem);
|
||||
}
|
||||
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.message.messageHovered
|
||||
.messageTimestampHover {
|
||||
opacity: 1;
|
||||
@@ -586,7 +630,11 @@
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.message:hover
|
||||
.messageTimestampHover {
|
||||
opacity: 0;
|
||||
@@ -650,7 +698,11 @@
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.messageCompact.messageHovered
|
||||
.messageTimestampCompactHover {
|
||||
opacity: 1;
|
||||
@@ -658,7 +710,11 @@
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.messageCompact:hover
|
||||
.messageTimestampCompactHover {
|
||||
opacity: 0;
|
||||
@@ -678,40 +734,80 @@
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.message .buttons,
|
||||
.messageCompact .buttons {
|
||||
:global(html:not(.experiment-message-hover-tracking)) .message .buttons,
|
||||
:global(html:not(.experiment-message-hover-tracking)) .messageCompact .buttons {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.message.messageHovered
|
||||
.buttons,
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.message
|
||||
.buttons:hover,
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.message
|
||||
.buttons:has(:focus-visible),
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.message.selected
|
||||
.buttons,
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.message
|
||||
.buttons.emojiPickerOpen,
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.messageCompact.messageHovered
|
||||
.buttons,
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.messageCompact
|
||||
.buttons:hover,
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.messageCompact
|
||||
.buttons:has(:focus-visible),
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.messageCompact.selected
|
||||
.buttons,
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.messageCompact
|
||||
.buttons.emojiPickerOpen {
|
||||
opacity: 1;
|
||||
@@ -719,10 +815,18 @@
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.message:hover
|
||||
.buttons,
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
:global(
|
||||
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
|
||||
.experiment-message-hover-tracking
|
||||
)
|
||||
)
|
||||
.messageCompact:hover
|
||||
.buttons {
|
||||
opacity: 0;
|
||||
@@ -1422,15 +1526,21 @@
|
||||
max-inline-size: calc(100% - var(--message-compact-text-start));
|
||||
}
|
||||
|
||||
.contextMenuActive {
|
||||
:global(html:not(.experiment-message-hover-tracking)) .contextMenuActive,
|
||||
:global(html.experiment-message-hover-tracking:where(.window-focused, .unfocused-fully-interactive))
|
||||
.contextMenuActive {
|
||||
background-color: var(--background-modifier-hover) !important;
|
||||
}
|
||||
|
||||
:global(html.reduced-motion) .contextMenuActive {
|
||||
:global(html.reduced-motion:not(.experiment-message-hover-tracking)) .contextMenuActive {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.contextMenuActive .buttons {
|
||||
:global(html.reduced-motion.experiment-message-hover-tracking) .contextMenuActive {
|
||||
background-color: var(--background-modifier-hover) !important;
|
||||
}
|
||||
|
||||
:global(html:not(.experiment-message-hover-tracking)) .contextMenuActive .buttons {
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
@@ -1445,7 +1555,9 @@
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.contextMenuActive.messageMentioned {
|
||||
:global(html:not(.experiment-message-hover-tracking)) .contextMenuActive.messageMentioned,
|
||||
:global(html.experiment-message-hover-tracking:where(.window-focused, .unfocused-fully-interactive))
|
||||
.contextMenuActive.messageMentioned {
|
||||
background-color: var(--message-mention-bg-hover) !important;
|
||||
}
|
||||
|
||||
@@ -1453,8 +1565,12 @@
|
||||
background-color: var(--message-mention-bg) !important;
|
||||
}
|
||||
|
||||
.contextMenuActive.messageReplying:not(.messageMentioned),
|
||||
.contextMenuActive.messageHighlight:not(.messageMentioned) {
|
||||
:global(html:not(.experiment-message-hover-tracking)) .contextMenuActive.messageReplying:not(.messageMentioned),
|
||||
:global(html:not(.experiment-message-hover-tracking)) .contextMenuActive.messageHighlight:not(.messageMentioned),
|
||||
:global(html.experiment-message-hover-tracking:where(.window-focused, .unfocused-fully-interactive))
|
||||
.contextMenuActive.messageReplying:not(.messageMentioned),
|
||||
:global(html.experiment-message-hover-tracking:where(.window-focused, .unfocused-fully-interactive))
|
||||
.contextMenuActive.messageHighlight:not(.messageMentioned) {
|
||||
background-color: var(--message-reply-bg) !important;
|
||||
}
|
||||
|
||||
@@ -1463,20 +1579,27 @@
|
||||
background-color: var(--message-reply-bg) !important;
|
||||
}
|
||||
|
||||
.keyboardFocused {
|
||||
:global(html:not(.experiment-message-hover-tracking)) .keyboardFocused,
|
||||
:global(html.experiment-message-hover-tracking:where(.window-focused, .unfocused-fully-interactive)) .keyboardFocused {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.keyboardFocused.messageMentioned {
|
||||
:global(html:not(.experiment-message-hover-tracking)) .keyboardFocused.messageMentioned,
|
||||
:global(html.experiment-message-hover-tracking:where(.window-focused, .unfocused-fully-interactive))
|
||||
.keyboardFocused.messageMentioned {
|
||||
background-color: var(--message-mention-bg);
|
||||
}
|
||||
|
||||
.keyboardFocused.messageReplying:not(.messageMentioned),
|
||||
.keyboardFocused.messageHighlight:not(.messageMentioned) {
|
||||
:global(html:not(.experiment-message-hover-tracking)) .keyboardFocused.messageReplying:not(.messageMentioned),
|
||||
:global(html:not(.experiment-message-hover-tracking)) .keyboardFocused.messageHighlight:not(.messageMentioned),
|
||||
:global(html.experiment-message-hover-tracking:where(.window-focused, .unfocused-fully-interactive))
|
||||
.keyboardFocused.messageReplying:not(.messageMentioned),
|
||||
:global(html.experiment-message-hover-tracking:where(.window-focused, .unfocused-fully-interactive))
|
||||
.keyboardFocused.messageHighlight:not(.messageMentioned) {
|
||||
background-color: var(--message-reply-bg);
|
||||
}
|
||||
|
||||
.keyboardFocused .buttons {
|
||||
:global(html:not(.experiment-message-hover-tracking)) .keyboardFocused .buttons {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import FocusRingContext, {type FocusRingContextManager} from '@app/features/ui/focus_ring/FocusRingContext';
|
||||
import FocusRingManager from '@app/features/ui/focus_ring/FocusRingManager';
|
||||
import FocusRingScope from '@app/features/ui/focus_ring/FocusRingScope';
|
||||
import {act, useContext, useRef} from 'react';
|
||||
import {createRoot, type Root} from 'react-dom/client';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
const RING_SELECTOR = '[data-flx="ui.focus-ring.focus-ring-scope.ring.focus-ring"]';
|
||||
|
||||
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type Slot = 'primary' | 'secondary';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let unmounted = false;
|
||||
const ringContexts: Partial<Record<Slot, FocusRingContextManager>> = {};
|
||||
const targets: Partial<Record<Slot, HTMLDivElement>> = {};
|
||||
|
||||
function Capture({slot = 'primary'}: {slot?: Slot}) {
|
||||
ringContexts[slot] = useContext(FocusRingContext);
|
||||
return (
|
||||
<div
|
||||
ref={(element) => {
|
||||
if (element != null) targets[slot] = element;
|
||||
}}
|
||||
data-flx="ui.focus-ring.focus-ring-scope-test.capture.div"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Harness({revision = 0}: {revision?: number}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<div ref={containerRef} data-revision={revision} data-flx="ui.focus-ring.focus-ring-scope-test.harness.div">
|
||||
<FocusRingScope
|
||||
containerRef={containerRef}
|
||||
data-flx="ui.focus-ring.focus-ring-scope-test.harness.focus-ring-scope"
|
||||
>
|
||||
<Capture data-flx="ui.focus-ring.focus-ring-scope-test.harness.capture" />
|
||||
</FocusRingScope>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopePair() {
|
||||
const primaryRef = useRef<HTMLDivElement>(null);
|
||||
const secondaryRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<>
|
||||
<div ref={primaryRef} data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair.div">
|
||||
<FocusRingScope
|
||||
containerRef={primaryRef}
|
||||
data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair.focus-ring-scope"
|
||||
>
|
||||
<Capture slot="primary" data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair.capture" />
|
||||
</FocusRingScope>
|
||||
</div>
|
||||
<div ref={secondaryRef} data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair.div--2">
|
||||
<FocusRingScope
|
||||
containerRef={secondaryRef}
|
||||
data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair.focus-ring-scope--2"
|
||||
>
|
||||
<Capture slot="secondary" data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair.capture--2" />
|
||||
</FocusRingScope>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function rings(): Array<Element> {
|
||||
return Array.from(container.querySelectorAll(RING_SELECTOR));
|
||||
}
|
||||
|
||||
function ring(): Element | null {
|
||||
return container.querySelector(RING_SELECTOR);
|
||||
}
|
||||
|
||||
function requireRing(): HTMLElement {
|
||||
const element = ring();
|
||||
if (element == null) throw new Error('The focus ring is not painted');
|
||||
return element as HTMLElement;
|
||||
}
|
||||
|
||||
function requireRingContext(slot: Slot = 'primary'): FocusRingContextManager {
|
||||
const value = ringContexts[slot];
|
||||
if (value == null) throw new Error(`The focus ring context for ${slot} never rendered`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireTarget(slot: Slot = 'primary'): HTMLDivElement {
|
||||
const value = targets[slot];
|
||||
if (value == null) throw new Error(`The focus ring target for ${slot} never rendered`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireScopeContainer(): HTMLElement {
|
||||
const element = container.firstElementChild;
|
||||
if (element == null) throw new Error('The scope container never rendered');
|
||||
return element as HTMLElement;
|
||||
}
|
||||
|
||||
function domRect(top: number, left: number, width: number, height: number): DOMRect {
|
||||
return {
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
x: left,
|
||||
y: top,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
function stubBoundingRect(element: Element, read: () => DOMRect) {
|
||||
Object.defineProperty(element, 'getBoundingClientRect', {value: read, configurable: true});
|
||||
}
|
||||
|
||||
function unmountRoot() {
|
||||
if (unmounted) return;
|
||||
unmounted = true;
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete ringContexts.primary;
|
||||
delete ringContexts.secondary;
|
||||
delete targets.primary;
|
||||
delete targets.secondary;
|
||||
unmounted = false;
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unmountRoot();
|
||||
container.remove();
|
||||
FocusRingManager.setRingsEnabled(true);
|
||||
});
|
||||
|
||||
describe('FocusRingScope', () => {
|
||||
test('paints the ring once rings are enabled, without a second focus event', () => {
|
||||
FocusRingManager.setRingsEnabled(false);
|
||||
act(() => {
|
||||
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness" />);
|
||||
});
|
||||
act(() => {
|
||||
requireRingContext().showForElement(requireTarget());
|
||||
});
|
||||
expect(ring()).toBeNull();
|
||||
act(() => {
|
||||
FocusRingManager.setRingsEnabled(true);
|
||||
});
|
||||
expect(ring()).not.toBeNull();
|
||||
});
|
||||
|
||||
test('restores the ring for a target that never blurred while rings were disabled', () => {
|
||||
FocusRingManager.setRingsEnabled(true);
|
||||
act(() => {
|
||||
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness--2" />);
|
||||
});
|
||||
act(() => {
|
||||
requireRingContext().showForElement(requireTarget());
|
||||
});
|
||||
expect(ring()).not.toBeNull();
|
||||
act(() => {
|
||||
FocusRingManager.setRingsEnabled(false);
|
||||
});
|
||||
expect(ring()).toBeNull();
|
||||
act(() => {
|
||||
FocusRingManager.setRingsEnabled(true);
|
||||
});
|
||||
expect(ring()).not.toBeNull();
|
||||
});
|
||||
|
||||
test('keeps the ring hidden when nothing is showing', () => {
|
||||
FocusRingManager.setRingsEnabled(false);
|
||||
act(() => {
|
||||
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness--3" />);
|
||||
});
|
||||
act(() => {
|
||||
FocusRingManager.setRingsEnabled(true);
|
||||
});
|
||||
expect(ring()).toBeNull();
|
||||
});
|
||||
|
||||
test('paints the ring on show and removes it on hide', () => {
|
||||
act(() => {
|
||||
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness--4" />);
|
||||
});
|
||||
expect(ring()).toBeNull();
|
||||
act(() => {
|
||||
requireRingContext().showForElement(requireTarget());
|
||||
});
|
||||
expect(ring()).not.toBeNull();
|
||||
act(() => {
|
||||
requireRingContext().hide();
|
||||
});
|
||||
expect(ring()).toBeNull();
|
||||
});
|
||||
|
||||
test('repositions the ring when a parent render moves a target that did not resize', () => {
|
||||
act(() => {
|
||||
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness--5" />);
|
||||
});
|
||||
stubBoundingRect(requireScopeContainer(), () => domRect(0, 0, 800, 600));
|
||||
let targetTop = 200;
|
||||
stubBoundingRect(requireTarget(), () => domRect(targetTop, 0, 300, 40));
|
||||
act(() => {
|
||||
requireRingContext().showForElement(requireTarget());
|
||||
});
|
||||
expect(requireRing().style.top).toBe('200px');
|
||||
targetTop = 320;
|
||||
act(() => {
|
||||
root.render(<Harness revision={1} data-flx="ui.focus-ring.focus-ring-scope-test.harness--6" />);
|
||||
});
|
||||
expect(requireRing().style.top).toBe('320px');
|
||||
});
|
||||
|
||||
test('repositions the ring when the target itself resizes, and stops observing on unmount', () => {
|
||||
const realResizeObserver = globalThis.ResizeObserver;
|
||||
const realRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const realCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
||||
let notifyResize: (() => void) | null = null;
|
||||
let disconnectCount = 0;
|
||||
class StubResizeObserver {
|
||||
constructor(callback: () => void) {
|
||||
notifyResize = callback;
|
||||
}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {
|
||||
disconnectCount += 1;
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver = StubResizeObserver as unknown as typeof ResizeObserver;
|
||||
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
}) as typeof requestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = (() => undefined) as typeof cancelAnimationFrame;
|
||||
try {
|
||||
act(() => {
|
||||
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness--7" />);
|
||||
});
|
||||
stubBoundingRect(requireScopeContainer(), () => domRect(0, 0, 800, 600));
|
||||
let targetHeight = 40;
|
||||
stubBoundingRect(requireTarget(), () => domRect(100, 0, 300, targetHeight));
|
||||
act(() => {
|
||||
requireRingContext().showForElement(requireTarget());
|
||||
});
|
||||
expect(requireRing().style.height).toBe('40px');
|
||||
targetHeight = 90;
|
||||
act(() => {
|
||||
notifyResize?.();
|
||||
});
|
||||
expect(requireRing().style.height).toBe('90px');
|
||||
unmountRoot();
|
||||
expect(disconnectCount).toBe(1);
|
||||
} finally {
|
||||
globalThis.ResizeObserver = realResizeObserver;
|
||||
globalThis.requestAnimationFrame = realRequestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = realCancelAnimationFrame;
|
||||
}
|
||||
});
|
||||
|
||||
test('drops the ring from the previous scope when another scope takes focus', () => {
|
||||
act(() => {
|
||||
root.render(<ScopePair data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair" />);
|
||||
});
|
||||
act(() => {
|
||||
requireRingContext('primary').showForElement(requireTarget('primary'));
|
||||
});
|
||||
expect(rings()).toHaveLength(1);
|
||||
act(() => {
|
||||
requireRingContext('secondary').showForElement(requireTarget('secondary'));
|
||||
});
|
||||
expect(rings()).toHaveLength(1);
|
||||
expect(requireRingContext('primary').visible).toBe(false);
|
||||
expect(requireRingContext('secondary').visible).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
canUseWindowFocusedHoverControls,
|
||||
createWindowFocusInteractionGuard,
|
||||
isWindowFocusActivationGuardActive,
|
||||
subscribeWindowHoverControlsChange,
|
||||
WINDOW_FOCUSED_CLASS,
|
||||
type WindowFocusInteractionGuard,
|
||||
} from '@app/features/ui/utils/WindowFocusInteractionGuard';
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const GUARD_TIMEOUT_MS = 10;
|
||||
|
||||
let activeGuard: WindowFocusInteractionGuard | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
function createFocusedGuard(): {guard: WindowFocusInteractionGuard; root: HTMLElement; observed: Array<boolean>} {
|
||||
const root = document.createElement('div');
|
||||
root.classList.add(WINDOW_FOCUSED_CLASS);
|
||||
document.body.append(root);
|
||||
const guard = createWindowFocusInteractionGuard({
|
||||
root,
|
||||
guardTimeoutMs: GUARD_TIMEOUT_MS,
|
||||
releaseClearDelayMs: 5,
|
||||
maxGuardTimeoutMs: 200,
|
||||
});
|
||||
activeGuard = guard;
|
||||
const observed: Array<boolean> = [];
|
||||
unsubscribe = subscribeWindowHoverControlsChange(() => observed.push(canUseWindowFocusedHoverControls(root)));
|
||||
return {guard, root, observed};
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
activeGuard?.destroy();
|
||||
activeGuard = null;
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe('WindowFocusInteractionGuard', () => {
|
||||
it('never publishes an enabled edge between refocus and the activation guard', async () => {
|
||||
const {guard, root, observed} = createFocusedGuard();
|
||||
|
||||
guard.setFocused(false);
|
||||
guard.setFocused(true);
|
||||
|
||||
expect(observed).toEqual([false]);
|
||||
expect(isWindowFocusActivationGuardActive(root)).toBe(true);
|
||||
expect(canUseWindowFocusedHoverControls(root)).toBe(false);
|
||||
|
||||
await wait(GUARD_TIMEOUT_MS * 3);
|
||||
|
||||
expect(observed).toEqual([false, true]);
|
||||
expect(canUseWindowFocusedHoverControls(root)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not publish an enabled edge while tearing down a guarded window', () => {
|
||||
const {guard, observed} = createFocusedGuard();
|
||||
|
||||
guard.setFocused(false);
|
||||
guard.setFocused(true);
|
||||
observed.length = 0;
|
||||
|
||||
guard.destroy();
|
||||
activeGuard = null;
|
||||
|
||||
expect(observed).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -141,14 +141,20 @@ export function createWindowFocusInteractionGuard({
|
||||
return {
|
||||
setFocused(nextFocused: boolean): void {
|
||||
focused = nextFocused;
|
||||
root.classList.toggle(WINDOW_FOCUSED_CLASS, nextFocused);
|
||||
notifyHoverControlsChange();
|
||||
if (!nextFocused) {
|
||||
root.classList.toggle(WINDOW_FOCUSED_CLASS, false);
|
||||
notifyHoverControlsChange();
|
||||
wasBlurred = true;
|
||||
clearActivationGuard();
|
||||
return;
|
||||
}
|
||||
if (wasBlurred) {
|
||||
const shouldGuardActivation = wasBlurred;
|
||||
if (shouldGuardActivation) {
|
||||
root.classList.add(WINDOW_FOCUS_ACTIVATION_GUARD_CLASS);
|
||||
}
|
||||
root.classList.toggle(WINDOW_FOCUSED_CLASS, true);
|
||||
notifyHoverControlsChange();
|
||||
if (shouldGuardActivation) {
|
||||
beginActivationGuard(false);
|
||||
}
|
||||
wasBlurred = false;
|
||||
@@ -162,9 +168,8 @@ export function createWindowFocusInteractionGuard({
|
||||
windowTarget.removeEventListener('pointercancel', handlePointerEnd, true);
|
||||
windowTarget.removeEventListener('click', handlePointerEnd, true);
|
||||
classObserver?.disconnect();
|
||||
clearActivationGuard();
|
||||
root.classList.remove(WINDOW_FOCUSED_CLASS);
|
||||
notifyHoverControlsChange();
|
||||
clearActivationGuard();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Admin API key objects and the operations that manage them.
|
||||
|
||||
import RouteHeader from '@/components/RouteHeader.astro';
|
||||
|
||||
An Admin API key is a long-lived credential for the [Admin API](/admin-api/). It authenticates as the account that created it, has its own [ACL](/admin-api/#acl-registry) set, and is honoured only on paths below `/v1/admin`.
|
||||
An Admin API key is a long-lived credential for the [Admin API](/admin-api/). It authenticates as the account that created it, has its own [ACL](/admin-api/#acl-registry) set, and authenticates only requests to paths below `/v1/admin`.
|
||||
|
||||
Every operation on this page requires `admin_api_key:manage`. None records an Admin audit entry or reads the `X-Audit-Log-Reason` header. An operation that addresses one key returns 404 `ADMIN_API_KEY_NOT_FOUND` when any of these holds:
|
||||
|
||||
@@ -20,7 +20,7 @@ The wildcard ACL reaches no key created by another account, and such a key repor
|
||||
|
||||
## Admin API key object
|
||||
|
||||
A key is owned by the account that created it, and it stores its own ACL set. Narrowing the owner and narrowing the key are separate actions.
|
||||
A key is owned by the account that created it, and it stores its own ACL set. Removing an ACL from the owning account and removing an ACL from the key are separate changes.
|
||||
|
||||
Fluxer checks both sets when a request presents a key. A request passes when all of these hold:
|
||||
|
||||
@@ -210,7 +210,7 @@ An update never rotates the credential, and no field on this route changes the e
|
||||
|
||||
<sup>1</sup> A value that is empty after trimming is rejected
|
||||
|
||||
<sup>2</sup> An empty array leaves the key with no ACLs, the narrowest state short of revocation. An empty request body is accepted and changes nothing
|
||||
<sup>2</sup> An empty array leaves the key with no ACLs, so the key satisfies no operation. An empty request body is accepted and changes nothing
|
||||
|
||||
### Response
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import RouteHeader from '@/components/RouteHeader.astro';
|
||||
|
||||
An application is an OAuth2 client that can own one bot account. These routes inspect applications and [transfer their ownership](#transfer-application-ownership).
|
||||
|
||||
These records are the same ones the public [Applications](/http-api/applications/) resource serves. Creation, deletion, renaming, bot creation, redirect URI editing, and credential rotation stay there.
|
||||
These records are the same ones the public [Applications](/http-api/applications/) resource serves. Creation, deletion, renaming, bot creation, redirect URI editing, and credential rotation are available only through that resource.
|
||||
|
||||
:::note[Admin operations report credential metadata only]
|
||||
The Admin application object reports whether a client secret or bot token hash is stored, when it was created, and the non-secret bot token preview.
|
||||
@@ -42,9 +42,9 @@ The Admin view of one application. An application and its bot account share one
|
||||
| client_secret_created_at<sup>4</sup> | ?ISO8601 timestamp | When the client secret was created |
|
||||
| version<sup>5</sup> | integer | The optimistic locking version of the stored record (0-2147483647) |
|
||||
|
||||
<sup>1</sup> Null when the owner account can no longer be read
|
||||
<sup>1</sup> Null when no account with `owner_user_id` exists
|
||||
|
||||
<sup>2</sup> Null when the application has no bot, and also null when the bot account can no longer be read
|
||||
<sup>2</sup> Null when the application has no bot, and also null when no account with `bot_user_id` exists
|
||||
|
||||
<sup>3</sup> The stored set arrives in no guaranteed order
|
||||
|
||||
@@ -85,11 +85,11 @@ Fluxer serves one synthetic application for its own Admin OAuth2 client. The app
|
||||
- `id` is the fixed constant `1234567890123456789`.
|
||||
- `name` is `Fluxer Admin`, and `owner_user_id` is the system account `0`.
|
||||
- `oauth2_redirect_uris` has one entry, the configured Admin endpoint followed by `/oauth2_callback`.
|
||||
- `has_client_secret` is true on every response that has it, and `client_secret_created_at` is null.
|
||||
- `has_client_secret` is true in every response that returns this application, and `client_secret_created_at` is null.
|
||||
- `version` is always 1.
|
||||
|
||||
:::caution[The built-in application is read-only]
|
||||
[Get application](#get-application) resolves it only while a client secret is configured for the deployment, and answers a null `application` otherwise. [List applications](#list-applications) never returns it. [Transfer application ownership](#transfer-application-ownership) answers 403 `FORBIDDEN`.
|
||||
[Get application](#get-application) returns it only while an Admin client secret is configured for the deployment, and returns a null `application` otherwise. [List applications](#list-applications) never returns it. [Transfer application ownership](#transfer-application-ownership) answers 403 `FORBIDDEN`.
|
||||
:::
|
||||
|
||||
## List applications
|
||||
@@ -172,7 +172,7 @@ Fluxer answers 200 with `application` set to null when the ID names no applicati
|
||||
|
||||
Moves the application to another account and returns the stored [Admin application](#admin-application-object) object. Requires `application:transfer_ownership`.
|
||||
|
||||
Ownership is the only field this operation writes. The new owner need not be related to the application, and Fluxer does not notify the previous owner.
|
||||
Ownership is the only field this operation writes. The new owner can be any existing account, and Fluxer does not notify the previous owner.
|
||||
|
||||
### Path parameters
|
||||
|
||||
@@ -186,7 +186,7 @@ Ownership is the only field this operation writes. The new owner need not be rel
|
||||
| --- | --- | --- |
|
||||
| new_owner_id<sup>1</sup> | snowflake | The account to transfer the application to |
|
||||
|
||||
<sup>1</sup> Fluxer resolves the application before the account, so an unknown application fails with 404 `UNKNOWN_APPLICATION` and an unknown replacement owner fails with 404 `UNKNOWN_USER`
|
||||
<sup>1</sup> An unknown application fails with 404 `UNKNOWN_APPLICATION`, and an unknown replacement owner fails with 404 `UNKNOWN_USER`. Fluxer looks up the application first, so a request where both are unknown fails with `UNKNOWN_APPLICATION`
|
||||
|
||||
### Response body
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import RouteHeader from '@/components/RouteHeader.astro';
|
||||
|
||||
An archive is a snapshot of one user account or one guild, built in the background and downloaded as a file. [Create user archive](/admin-api/users/#create-user-archive) and [Create guild archive](/admin-api/guilds/#create-guild-archive) request one. The operations here read archive state and issue time-limited download URLs.
|
||||
|
||||
Assets that no longer exist are omitted. Other read or write failures fail the attempt.
|
||||
While Fluxer builds an archive, it leaves out any stored file that is missing from storage. Any other read or write failure fails that build attempt and sets `failed_at`.
|
||||
|
||||
Every operation needs an [ACL](/admin-api/#acl-evaluation) covering the subject type it touches.
|
||||
|
||||
@@ -22,7 +22,7 @@ An archive becomes unavailable through these routes at `expires_at`, 365 days af
|
||||
|
||||
## Archive object
|
||||
|
||||
An archive is in one of these lifecycle states. It is building while `completed_at` and `failed_at` are both null, complete once `completed_at` is set, and failed once `failed_at` is set. A retried attempt clears `failed_at` and `error_message` when it starts, so a failed archive reads as building again while the retry runs.
|
||||
An archive is in one of these lifecycle states. It is building while `completed_at` and `failed_at` are both null, complete once `completed_at` is set, and failed once `failed_at` is set. When Fluxer starts a new attempt at a failed archive, it clears `failed_at` and `error_message`, so the archive reads as building again while that attempt runs.
|
||||
|
||||
### Structure
|
||||
|
||||
@@ -121,7 +121,7 @@ Returns [archive](#archive-object) objects matching the supplied filters, newest
|
||||
|
||||
<sup>2</sup> `subject_type` also has to name `user` or `guild`
|
||||
|
||||
<sup>3</sup> Ignored when `subject_id` is supplied. When it does apply it runs regardless of `subject_type`
|
||||
<sup>3</sup> Ignored when `subject_id` is supplied. When `requested_by` applies, Fluxer ignores `subject_type` and returns that account's archives of both subject types
|
||||
|
||||
<sup>4</sup> The listing is not paginated and returns no cursor. Only a narrower filter reaches older records
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: The safety blocklists, their value forms, and the operations that m
|
||||
|
||||
import RouteHeader from '@/components/RouteHeader.astro';
|
||||
|
||||
Fluxer has nine blocklists for account access and user content. Each defines its accepted values, matching rules and metadata. Values are normalised consistently when added and checked.
|
||||
Fluxer has nine blocklists for account access and user content. Each defines its accepted values, matching rules and metadata. Fluxer normalises a value when it adds the value and again when it checks the value.
|
||||
|
||||
Each list has its own [Admin ACLs](/admin-api/#acl-registry). A read needs the selected list's `check` permission, an addition or an update needs its `add` permission, and a removal needs its `remove` permission. An account holding `ban:ip:add` writes to the `ip` list and to no other. Every write records the audit reason on the [Admin audit entries](/admin-api/#admin-audit-entry-object) it produces. The reads record nothing.
|
||||
|
||||
@@ -32,13 +32,13 @@ Fluxer synchronises disposable email domains from external feeds every six hours
|
||||
| avatar-hash<sup>8</sup> | Avatar hashes blocked from being set |
|
||||
| profile-substring<sup>4</sup> <sup>9</sup> | Substrings blocked from one named profile field |
|
||||
|
||||
<sup>1</sup> Fluxer refuses an address with 400 `IP_BAN_DECLINED` when it is on the instance exemption list, or when the high blast-radius carrier network check matches, and records both refusals in the Admin audit log
|
||||
<sup>1</sup> Fluxer refuses an address with 400 `IP_BAN_DECLINED` when it is on the instance exemption list, or when IP lookup data shows that a single address is on a mobile carrier network, and records both refusals in the Admin audit log
|
||||
|
||||
<sup>2</sup> Stored lowercased, so a mixed-case value does not create a second row
|
||||
|
||||
<sup>3</sup> Fluxer never shows the list to the account holder, who sees only the verified-phone gate. A domain must match `^[a-zA-Z0-9][a-zA-Z0-9\-.]*\.[a-zA-Z]{2,}$`
|
||||
|
||||
<sup>4</sup> Canonicalised by NFKC normalisation, removal of control, format and variation-selector characters, lowercasing, and trimming. Match time also normalises inserted whitespace, punctuation, and compatibility characters
|
||||
<sup>4</sup> Canonicalised by NFKC normalisation, removal of control, format and variation-selector characters, lowercasing, and trimming. When Fluxer matches a value, it also normalises inserted whitespace, punctuation, and compatibility characters
|
||||
|
||||
<sup>5</sup> Canonicalised before storage. A value Fluxer cannot canonicalise returns 400 `INVALID_FORM_BODY` naming `url`
|
||||
|
||||
@@ -214,7 +214,7 @@ A value can be blocked with no row of its own, as a single address covered by a
|
||||
|
||||
## Blocklist entry creation object
|
||||
|
||||
The body of [Add blocklist entry](#add-blocklist-entry) is a union resolved by the `list_type` path segment. The field that has the value differs per blocklist, and two of the nine take an array, so one request can add up to 1000 avatar hashes or profile substrings.
|
||||
The body of [Add blocklist entry](#add-blocklist-entry) has one shape per blocklist, and the `list_type` path segment selects the shape. The field that has the value differs per blocklist, and two of the nine take an array, so one request can add up to 1000 avatar hashes or profile substrings.
|
||||
|
||||
### Value field
|
||||
|
||||
@@ -365,13 +365,13 @@ Every write is an upsert on the canonical value. An omitted optional field is wr
|
||||
|
||||
A body field the selected blocklist does not accept is stripped and never produces that 400. A `url` Fluxer cannot canonicalise returns 400 `INVALID_FORM_BODY` naming `url` in the `errors` array.
|
||||
|
||||
Fluxer refuses to add an `ip` with 400 `IP_BAN_DECLINED` when the address is on the instance exemption list, and when a single address is classified as a high blast-radius mobile or carrier network. It skips the blast-radius guard for a CIDR range, and a lookup failure counts as no risk, so the address is written.
|
||||
Fluxer refuses to add an `ip` with 400 `IP_BAN_DECLINED` when the address is on the instance exemption list, and when a single address is classified as a high blast-radius mobile or carrier network. Fluxer runs that carrier network check only for a single address, so the check never refuses a CIDR range. When the IP lookup for the check fails, Fluxer treats the address as low risk and writes it.
|
||||
|
||||
The response has no body, so it does not report the canonical form that was stored. Read it back with [List blocklist entries](#list-blocklist-entries).
|
||||
|
||||
### Side effects
|
||||
|
||||
The written rows take effect for subsequent blocklist decisions. For every list except `email` and `email-domain-suspicious`, other nodes see the rows after a short propagation delay. No Gateway Dispatch is emitted.
|
||||
Fluxer checks later requests against the written rows. For every list except `email` and `email-domain-suspicious`, other nodes see the rows after a short propagation delay. No Gateway Dispatch is emitted.
|
||||
|
||||
Fluxer records one [Admin audit entry](/admin-api/#admin-audit-entry-object) per written value, with that value in its metadata. It records an entry for a refused `ip` too, under the action `ban_ip_skipped_exempt` or `ban_ip_skipped_cgnat`, before it returns the 400.
|
||||
|
||||
@@ -469,7 +469,7 @@ The operation is idempotent and reports no counts. A value with no stored row st
|
||||
|
||||
### Side effects
|
||||
|
||||
Removed entries stop affecting subsequent blocklist decisions after changes propagate across the instance. A value can remain blocked by another matching entry. No Gateway Dispatch is emitted.
|
||||
After the removal reaches every node, Fluxer no longer checks later requests against the removed entries. A value can remain blocked by another matching entry. No Gateway Dispatch is emitted.
|
||||
|
||||
The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) per canonical value processed.
|
||||
|
||||
@@ -518,7 +518,7 @@ Reports whether one value is currently blocked by the selected blocklist and ret
|
||||
|
||||
<sup>4</sup> Two addresses are the same origin when they share a decision key, which is the exact address for IPv4 and the `/64` prefix for IPv6
|
||||
|
||||
<sup>5</sup> A domain the account policy exempts from contact-domain reputation reads as not blocked even while a row exists
|
||||
<sup>5</sup> A domain that the instance account policy marks as exempt from reputation checks reads as not blocked even while a row exists
|
||||
|
||||
<sup>6</sup> This check does not read the `url-domain` list, so a URL that a stored domain blocks reads as not blocked. Check the hostname separately
|
||||
|
||||
@@ -598,7 +598,7 @@ Changing the `scope` of a `profile-substring` row writes a second row under the
|
||||
|
||||
### Side effects
|
||||
|
||||
The updated fields take effect for subsequent matches after changes propagate across the instance. No Gateway Dispatch is emitted.
|
||||
After the change reaches every node, later matches use the updated fields. No Gateway Dispatch is emitted.
|
||||
|
||||
The write records one [Admin audit entry](/admin-api/#admin-audit-entry-object) under the same action an add records, with the canonical value but no previous values of the changed fields.
|
||||
|
||||
@@ -692,7 +692,7 @@ Both fields are optional, so an empty body and `{}` are both valid requests.
|
||||
|
||||
The account keeps the blocked avatar until it next sets one. Clearing it is a separate request naming `avatar` in the `fields` array of [Clear user profile fields](/admin-api/users/#clear-user-profile-fields).
|
||||
|
||||
Every account using that image shares the same truncated prefix, so blocking the hash blocks the image for every account.
|
||||
The avatar hash is the first 8 characters of the MD5 digest of the image, so every account using that image has the same avatar hash, so blocking the hash blocks the image for every account.
|
||||
|
||||
### Side effects
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ Body validation runs ahead of authentication and ACL evaluation, so a malformed
|
||||
|
||||
### JSON body
|
||||
|
||||
The `task` discriminator selects one of these structures. Every ID array has an upper bound and no lower bound, so an empty array queues a job that processes nothing and settles as `succeeded`.
|
||||
The `task` discriminator selects one of these structures. Every ID array has an upper bound and no lower bound, so an empty array queues a job that processes nothing and finishes with the status `succeeded`.
|
||||
|
||||
#### Update user flags structure
|
||||
|
||||
@@ -86,7 +86,7 @@ The `task` discriminator selects one of these structures. Every ID array has an
|
||||
| add_flags?<sup>1</sup> | array[string] | [Account flag](/admin-api/users/#account-flags) values to add (max 64, default empty) |
|
||||
| remove_flags?<sup>1</sup> | array[string] | [Account flag](/admin-api/users/#account-flags) values to remove (max 64, default empty) |
|
||||
|
||||
<sup>1</sup> Each entry is one 64-bit flag value written as an unsigned decimal string, such as `1024`. The boundary rejects a symbolic name. Additions are applied before removals, so a value named in both arrays ends up cleared
|
||||
<sup>1</sup> Each entry is one 64-bit flag value written as an unsigned decimal string, such as `1024`. Body validation rejects a symbolic name. Additions are applied before removals, so a value named in both arrays ends up cleared
|
||||
|
||||
#### Update suspicious activity flags structure
|
||||
|
||||
@@ -110,7 +110,7 @@ The `task` discriminator selects one of these structures. Every ID array has an
|
||||
| add_features?<sup>1</sup> | array[string] | [Guild features](/http-api/guilds/#guild-features) to add (max 100, default empty) |
|
||||
| remove_features?<sup>1</sup> | array[string] | [Guild features](/http-api/guilds/#guild-features) to remove (max 100, default empty) |
|
||||
|
||||
<sup>1</sup> The boundary accepts any string, so a name outside the registry is written to the guild's feature set. Additions are applied before removals
|
||||
<sup>1</sup> Body validation accepts any string, so a name outside the registry is written to the guild's feature set. Additions are applied before removals
|
||||
|
||||
#### Add guild members structure
|
||||
|
||||
@@ -148,7 +148,7 @@ The [job](/admin-api/jobs/) records the acting Admin and audit reason. Queueing
|
||||
|
||||
Entities are processed in the submitted order. [Cancel job](/admin-api/jobs/#cancel-job) stops the run between entities and sets its status to `cancelled`. Completed changes remain in place. A failed or unknown entity counts as failed without stopping the remaining work.
|
||||
|
||||
Progress updates arrive before work starts, after every 25 entities, and at completion. `schedule_user_deletion` updates after every 10 accounts instead. The final message includes successful and failed counts.
|
||||
Every task updates its progress before work starts and at completion. Every task except `schedule_user_deletion` also updates its progress after every 25 entities, and `schedule_user_deletion` updates its progress after every 10 accounts. The final `progress_message` has the successful and failed counts.
|
||||
|
||||
Every task writes one summary Admin audit entry when it finishes, with the action `bulk_update_user_flags`, `bulk_update_suspicious_activity_flags`, `bulk_update_guild_features`, `bulk_add_guild_members`, `bulk_schedule_deletion`, `bulk_ban_file_shas`, or `bulk_delete_user_messages`. The summary has the audit reason, the entity count, the operation-specific parameters, the job identifier, and the processed, successful, and failed counts. Its `target_type` is `bulk_job` and its `target_id` is the job identifier, except for `add_guild_members`, which targets the guild. A failed job writes no summary entry. A cancelled job writes one, marked `cancelled`, covering the entities it processed before it stopped.
|
||||
|
||||
@@ -158,7 +158,7 @@ Every task writes one summary Admin audit entry when it finishes, with the actio
|
||||
|
||||
`update_guild_features` writes one `update_features` entry for each guild, dispatches [Guild Update](/gateway/events/#guild-update), and reindexes the guild for search. Fluxer reconciles a guild that already has a discovery application record against the new feature set, so gaining `DISCOVERABLE` approves the record and losing it marks the record removed. A guild with no discovery record is left alone.
|
||||
|
||||
`add_guild_members` bypasses the ban check and the risk gate. The task suppresses the join system message, records the join source as an Admin force add, and dispatches [Guild Member Add](/gateway/events/#guild-member-add) to the guild and [Guild Create](/gateway/events/#guild-create) to the added account's sessions. The task still enforces the per-account guild cap and the guild member cap, so an account at either ceiling is counted as failed. An account that is already a member is left unchanged and counted as successful, with no second membership and no Dispatch. Adding a bot account also records a `BOT_ADD` guild audit log entry attributed to the acting Admin.
|
||||
`add_guild_members` bypasses the ban check and the deferred phone verification check that a normal join runs for an account without a verified phone. The task suppresses the join system message, records the join source as an Admin force add, and dispatches [Guild Member Add](/gateway/events/#guild-member-add) to the guild and [Guild Create](/gateway/events/#guild-create) to the added account's sessions. The task still enforces the per-account guild cap and the guild member cap, so an account at either ceiling is counted as failed. An account that is already a member is left unchanged and counted as successful, with no second membership and no Dispatch. Adding a bot account also records a `BOT_ADD` guild audit log entry attributed to the acting Admin.
|
||||
|
||||
`delete_user_messages` deletes every message each account wrote, across every channel. It writes one `delete_all_user_messages` entry for each account, with the audit reason, the account, and the deleted message count, and reports progress after every account.
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ A guild's submitted application, together with the guild details Fluxer resolves
|
||||
| custom_tags<sup>6</sup> <sup>8</sup> | array[string] | Normalised [custom tags](/http-api/discovery/#custom-tags) of the listing |
|
||||
| applied_at | ISO8601 timestamp | Time at which the guild applied |
|
||||
|
||||
<sup>1</sup> Resolved from the guild at request time. A guild that can no longer be resolved yields the literal name `(unknown guild)`, a null icon, the owner ID `0`, a member count of 0, a null NSFW level, an empty feature array, and null for all owner name fields
|
||||
<sup>1</sup> Resolved from the guild at request time. A guild Fluxer fails to load yields the literal name `(unknown guild)`, a null icon, the owner ID `0`, a member count of 0, a null NSFW level, an empty feature array, and null for all owner name fields
|
||||
|
||||
<sup>2</sup> Read from the owner account, so it is null when the guild resolved but the owner account did not
|
||||
|
||||
@@ -92,7 +92,7 @@ An approved listing. It has every field of the [Admin pending application object
|
||||
|
||||
## Discovery application object
|
||||
|
||||
This is the [discovery application object](/http-api/discovery/#discovery-application-object) of the public Discovery resource. Every write on this page answers with it, so a client re-reads the listing after a write to render the enriched guild fields. No response on this page exposes the Admin that approved, rejected, or removed the application.
|
||||
This is the [discovery application object](/http-api/discovery/#discovery-application-object) of the public Discovery resource. Every write on this page answers with it, so a client that needs the guild name, icon, owner, member count, NSFW level, or features re-reads the listing after a write. No response on this page exposes the Admin that approved, rejected, or removed the application.
|
||||
|
||||
### Structure
|
||||
|
||||
@@ -317,7 +317,7 @@ Files every named guild under one [discovery category](/http-api/discovery/#disc
|
||||
Fluxer attempts the guilds one at a time and in order, and a failure does not stop the ones after it. Read `failed_guild_ids` for the guilds that did not move. The operation never returns 404.
|
||||
:::
|
||||
|
||||
Repeating the request is safe. A pending application moves on the same terms as an approved one, so this operation can refile a guild that is still awaiting review.
|
||||
Repeating the request is safe. The operation moves a pending application the same way it moves an approved one, so this operation can refile a guild that is still awaiting review.
|
||||
|
||||
### Side effects
|
||||
|
||||
@@ -371,7 +371,7 @@ Every member is optional. An omitted member preserves the stored value, and an e
|
||||
|
||||
### Side effects
|
||||
|
||||
The supplied members update the application while preserving its status, submission time, review time, review reason, and removal fields. Editing an approved listing does not return it to review. The new copy is visible in the next public search. Editing a pending application changes nothing a public reader can see.
|
||||
The supplied members update the application while preserving its status, submission time, review time, review reason, and removal fields. Editing an approved listing does not return it to review. The new description, category, language, and tags appear in the next public search. Editing a pending application changes nothing a public reader can see.
|
||||
|
||||
No guild field changes, so no [Guild Update](/gateway/events/#guild-update) fires.
|
||||
|
||||
|
||||
@@ -245,7 +245,7 @@ The counts cover voice states in guild channels and in calls. A node that does n
|
||||
|
||||
<RouteHeader method="POST" path="/v1/admin/gateway/reloads" />
|
||||
|
||||
Rebuilds server-side state for the supplied guilds from the database and returns how many live guild processes were selected. Requires `gateway:reload_all`.
|
||||
Fetches the guild data for the supplied guilds from the database again and sends it to each live guild process and returns how many live guild processes were selected. Requires `gateway:reload_all`.
|
||||
|
||||
### JSON body
|
||||
|
||||
@@ -277,7 +277,7 @@ Every reloaded guild process fires one [Guild Update](/gateway/events/#guild-upd
|
||||
|
||||
| Status | Body | Condition |
|
||||
| --- | --- | --- |
|
||||
| 200 | response body | Every selected guild process was dispatched |
|
||||
| 200 | response body | Fluxer started a reload for every selected guild process |
|
||||
|
||||
### Side effects
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ Admin gift code generation issues [premium](/http-api/premium/) codes in bulk, e
|
||||
This resource has that one operation. No Admin operation lists, revokes, or redeems a code, or reports who redeemed one.
|
||||
|
||||
:::caution[A self-hosted instance answers 403]
|
||||
The error code is `FEATURE_NOT_AVAILABLE_SELF_HOSTED`. Body validation runs first, so a request that also fails validation is refused by that failure.
|
||||
The error code is `FEATURE_NOT_AVAILABLE_SELF_HOSTED`. Body validation runs first, so a self-hosted instance answers a body that fails validation with 400 `INVALID_FORM_BODY`.
|
||||
:::
|
||||
|
||||
## Gift duration units
|
||||
|
||||
The unit and the quantity together define the span a redeemed code grants. Fluxer adds the span to the redeemer's entitlement anchor at redemption time. Lifetime gifts are not supported.
|
||||
The unit and the quantity together define the span a redeemed code grants. At redemption, Fluxer adds the span to the entitlement anchor, which is the latest of the current time, the end of the redeemer's premium, and the end of any earlier gift extension. Lifetime gifts are not supported.
|
||||
|
||||
| Value | Description |
|
||||
| --- | --- |
|
||||
|
||||
@@ -47,7 +47,7 @@ The compact guild representation returned by [List guilds](#list-guilds) and by
|
||||
|
||||
<sup>3</sup> No operation populates these, so they are absent from every current response
|
||||
|
||||
<sup>4</sup> Present only on [List user guilds](/admin-api/users/#list-user-guilds) when that operation is asked for counts
|
||||
<sup>4</sup> Present only on [List user guilds](/admin-api/users/#list-user-guilds) when the request sets `with_counts` to true
|
||||
|
||||
### Example
|
||||
|
||||
@@ -175,7 +175,7 @@ A custom emoji or a sticker of a guild, with a resolvable media URL. The listing
|
||||
| creator_id | snowflake | The account that uploaded the expression |
|
||||
| media_url<sup>1</sup> | string | The [Media Proxy](/media-proxy/routes/) URL the expression is served from (1-2048 characters) |
|
||||
|
||||
<sup>1</sup> Always the WebP representation, at size 160 for an emoji and size 320 for a sticker, and it has the `animated=true` selector only when `animated` is true
|
||||
<sup>1</sup> Always the WebP representation, at size 160 for an emoji and size 320 for a sticker, and the URL has the `animated=true` query parameter only when `animated` is true
|
||||
|
||||
### Example
|
||||
|
||||
@@ -218,7 +218,7 @@ One entry of the `errors` array returned by [Purge guild assets](#purge-guild-as
|
||||
| id | string | The asset that could not be purged, as supplied |
|
||||
| error<sup>1</sup> | string | The reason the asset could not be purged (1-4000 characters) |
|
||||
|
||||
<sup>1</sup> The operation produces `Invalid numeric ID`, `Asset belongs to another guild`, and `Failed to purge asset`. Any other value is the message of the underlying failure
|
||||
<sup>1</sup> The operation produces `Invalid numeric ID`, `Asset belongs to another guild`, and `Failed to purge asset`. Any other value is the message of the error Fluxer hit while purging that ID
|
||||
|
||||
## Asset types
|
||||
|
||||
@@ -226,7 +226,7 @@ One entry of the `errors` array returned by [Purge guild assets](#purge-guild-as
|
||||
| --- | --- |
|
||||
| emoji | The ID resolved to a custom emoji owned by the requested guild |
|
||||
| sticker | The ID resolved to a sticker owned by the requested guild |
|
||||
| unknown | The ID matched no emoji and no sticker record, and only associated media was queued for removal |
|
||||
| unknown | The ID matched no emoji or sticker, and Fluxer queued removal of any emoji and sticker media stored under that ID |
|
||||
|
||||
An ID owned by a different guild appears in `errors` with no asset type.
|
||||
|
||||
@@ -246,7 +246,7 @@ The index matches `q` against the guild name, the discovery tags, the custom inv
|
||||
| limit? | integer | Maximum guilds to return (1-200, default 50) |
|
||||
| offset? | integer | Guilds to skip before returning results (0-10000, default 0) |
|
||||
|
||||
<sup>1</sup> A `q` of all decimal digits also resolves that value as an exact guild ID, but only while `offset` is 0. A guild the index did not return is prepended to `guilds` and adds one to `total`
|
||||
<sup>1</sup> A `q` of all decimal digits also resolves that value as an exact guild ID, but only while `offset` is 0. When the index did not return the guild with that ID, Fluxer prepends it to `guilds` and adds one to `total`
|
||||
|
||||
### Response body
|
||||
|
||||
@@ -356,7 +356,7 @@ A body with no field at all selects nothing, so an empty patch applies no change
|
||||
|
||||
A code already claimed by any invite is rejected with 400 `INVALID_FORM_BODY` and the validation code `THIS_VANITY_URL_IS_ALREADY_TAKEN` against `vanity_url_code`.
|
||||
|
||||
The channel references, idle timeout, and message history cutoff of a guild are not Admin-writable. They change through the public [Modify guild](/http-api/guilds/#modify-guild) operation.
|
||||
`afk_channel_id`, `system_channel_id`, `afk_timeout`, and `message_history_cutoff` of a guild are not Admin-writable. They change through the public [Modify guild](/http-api/guilds/#modify-guild) operation.
|
||||
|
||||
### Response body
|
||||
|
||||
@@ -490,7 +490,7 @@ A member query the main Gateway reports as failed returns 502 `BAD_GATEWAY`, an
|
||||
|
||||
Adds a user to a guild without an invite. Requires `guild:force_add_member`. The operation takes no request body.
|
||||
|
||||
Only the Admin ACL is evaluated.
|
||||
Fluxer checks only the Admin ACL. The acting account needs no membership and no permission in the guild.
|
||||
|
||||
### Path parameters
|
||||
|
||||
@@ -515,11 +515,11 @@ Only the Admin ACL is evaluated.
|
||||
|
||||
### Side effects
|
||||
|
||||
Fluxer creates the membership with the Admin force-add join source and restores a communication timeout still in force from a previous membership. The guild ban list is not checked, so a banned user can be admitted. The suspicious activity phone gate does not run. The per-user guild limit and the guild member limit are still enforced.
|
||||
Fluxer creates the membership with the Admin force-add join source and restores a communication timeout still in force from a previous membership. The guild ban list is not checked, so a banned user can be admitted. The [deferred phone gate](/admin-api/instance/#deferred-phone-gate-object) does not run. The per-user guild limit and the guild member limit are still enforced.
|
||||
|
||||
[Guild Member Add](/gateway/events/#guild-member-add) fires to the guild, and the user's sessions are joined to the guild on the main Gateway. The member enters guild member search when the guild has an indexed member set. The ordinary join system message is created, and with it a [Message Create](/gateway/events/#message-create) Dispatch, unless the guild sets `SUPPRESS_JOIN_NOTIFICATIONS` or has no usable system channel. A bot target also records a `BOT_ADD` entry in the guild's own [audit log](/http-api/guild-audit-logs/#audit-actions).
|
||||
[Guild Member Add](/gateway/events/#guild-member-add) fires to the guild, and the user's sessions are joined to the guild on the main Gateway. When the guild's members are already indexed for search, Fluxer adds the new member to guild member search. The ordinary join system message is created, and with it a [Message Create](/gateway/events/#message-create) Dispatch, unless the guild sets `SUPPRESS_JOIN_NOTIFICATIONS` or has no `system_channel_id`, or its `system_channel_id` names a channel that no longer exists. A bot target also records a `BOT_ADD` entry in the guild's own [audit log](/http-api/guild-audit-logs/#audit-actions).
|
||||
|
||||
A user who is already a member keeps their existing membership. No membership is created, no counter moves, and no Dispatch is emitted. The Admin audit entry is still written.
|
||||
A user who is already a member keeps their existing membership. No membership is created, `member_count` does not change, and no Dispatch is emitted. The Admin audit entry is still written.
|
||||
|
||||
One Admin audit entry is recorded with the action `force_add_to_guild`, the target type `user`, the added user as the target, and the guild ID in the metadata.
|
||||
|
||||
@@ -557,7 +557,7 @@ The acting account must see the guild, hold [KICK_MEMBERS](/http-api/permissions
|
||||
|
||||
### Side effects
|
||||
|
||||
The user loses membership and disappears from guild member search. A later rejoin restores previous membership settings, including any communication timeout still in force.
|
||||
The user loses membership and disappears from guild member search. A later rejoin restores a communication timeout that is still in force. The rejoined member starts with no nickname and no roles.
|
||||
|
||||
[Guild Member Remove](/gateway/events/#guild-member-remove) fires to the guild. A `MEMBER_KICK` entry is written to the guild's own [audit log](/http-api/guild-audit-logs/#audit-actions). The entry names the acting Admin account and has the audit reason. The write fires [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) to sessions holding [VIEW_AUDIT_LOG](/http-api/permissions/).
|
||||
|
||||
@@ -735,7 +735,7 @@ Every ID in `processed` loses its record and its stored media permanently. The o
|
||||
|
||||
A record owned by the guild in the path is deleted, and its stored media is queued for removal. The guild then receives one [Guild Emojis Update](/gateway/events/#guild-emojis-update) or [Guild Stickers Update](/gateway/events/#guild-stickers-update) Dispatch with its complete remaining expression set. A request that purges several records emits one such Dispatch per record.
|
||||
|
||||
A record owned by a different guild is left untouched and reported in `errors`. An ID with no record queues emoji and sticker media removal for that ID and is reported with the `unknown` asset type, so the operation also clears orphaned media.
|
||||
A record owned by a different guild is left untouched and reported in `errors`. An ID with no record queues emoji and sticker media removal for that ID and is reported with the `unknown` asset type, so the operation also removes stored media that has no emoji or sticker record.
|
||||
|
||||
Every entry in `processed` records its own Admin audit entry, with the numeric ID as the target. A purged emoji records `purge_guild_emoji_asset` with the target type `guild_emoji`, a purged sticker records `purge_guild_sticker_asset` with `guild_sticker`, and an `unknown` ID records `purge_asset` with `asset`. Entries in `errors` record nothing.
|
||||
|
||||
@@ -749,7 +749,7 @@ Every entry in `processed` records its own Admin audit entry, with the numeric I
|
||||
|
||||
Returns one page of the guild's own in-app audit log, without requiring guild membership or [VIEW_AUDIT_LOG](/http-api/permissions/). Requires `guild:audit_log:view`.
|
||||
|
||||
The page has the same shape and semantics as the public [List guild audit logs](/http-api/guild-audit-logs/#list-guild-audit-logs) operation, including its message deletion consolidation.
|
||||
The response has the same fields, and its query parameters behave the same way, as in the public [List guild audit logs](/http-api/guild-audit-logs/#list-guild-audit-logs) operation, including its message deletion consolidation.
|
||||
|
||||
### Path parameters
|
||||
|
||||
@@ -864,7 +864,7 @@ A shutdown the main Gateway reports as failed returns 502 `BAD_GATEWAY`, an unan
|
||||
|
||||
### Side effects
|
||||
|
||||
The main Gateway stops the guild process. Every session subscribed to the guild receives a [Guild Delete](/gateway/events/#guild-delete) Dispatch with `unavailable` true and reconnects after one second, which starts the guild again from stored data. Stored guild data is untouched, and [Reload guild](#reload-guild) also starts a stopped guild.
|
||||
The main Gateway stops the guild process. Every session subscribed to the guild receives a [Guild Delete](/gateway/events/#guild-delete) Dispatch with `unavailable` true. One second later the main Gateway reconnects each of those sessions to the guild, which starts the guild again from stored data. Stored guild data is untouched, and [Reload guild](#reload-guild) also starts a stopped guild.
|
||||
|
||||
One Admin audit entry is recorded with the action `shutdown_guild`, the target type `guild`, and the guild ID in both the target and the metadata.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Fluxer evaluates the credential in a fixed order.
|
||||
2. A credential presented with the `Bot` scheme is refused with 401 `UNAUTHORIZED`.
|
||||
3. Any access token issued to an OAuth2 application other than the built-in Admin application is refused with 403 `ACCESS_DENIED`.
|
||||
4. An account holding neither `admin:authenticate` nor the wildcard `*` is refused with 403 `MISSING_PERMISSIONS`.
|
||||
5. An account that clears both gates but satisfies none of the [ACLs](#acl-evaluation) the operation names is refused with 403 `MISSING_ACL`.
|
||||
5. An account that passes every check above but satisfies none of the [ACLs](#acl-evaluation) the operation names is refused with 403 `MISSING_ACL`.
|
||||
|
||||
:::caution[A denial names no requirement]
|
||||
The `MISSING_ACL` body is a fixed sentence with no ACL name and no requirement list, so a denial never says which requirement failed.
|
||||
@@ -26,9 +26,9 @@ The `MISSING_ACL` body is a fixed sentence with no ACL name and no requirement l
|
||||
|
||||
No Admin operation declares an OAuth2 scope, an MFA requirement, or a sudo requirement, so none consumes the `X-Fluxer-Sudo-Mode-JWT` header. An accepted credential together with the required ACLs is the complete authorisation boundary.
|
||||
|
||||
An Admin API key authenticates as the account that created it, and Fluxer reads it only on a path below `/v1/admin`. A request presenting a key on any other path continues with no credential resolved. Fluxer authorises a key-authenticated request twice. The key's own ACL set must satisfy the operation, and unless the owning account holds `*` that account must itself hold one of the ACLs the operation names. Removing an ACL from either side narrows every existing key at once, with no rotation.
|
||||
An Admin API key authenticates as the account that created it, and Fluxer reads it only on a path below `/v1/admin`. A request presenting a key on any other path is treated as a request with no credential. Fluxer authorises a key-authenticated request twice. The key's own ACL set must satisfy the operation, and unless the owning account holds `*` that account must itself hold one of the ACLs the operation names. When an ACL is removed from a key, or from the account that owns the key, the key no longer passes a check for that ACL on its next request. The key does not need to be rotated.
|
||||
|
||||
Initial setup is the single exception. While the instance reports its setup as unconfigured, a session credential reaches the [instance configuration](/admin-api/instance/) operations without holding any ACL. The session that switches setup to configured is granted `*` immediately. Once setup is configured those operations apply the boundary above.
|
||||
Initial setup is the single exception. While `app_public.setup.configured` is false, a session credential reaches the [instance configuration](/admin-api/instance/) operations without holding any ACL. The session that switches setup to configured is granted `*` immediately. After `app_public.setup.configured` becomes true, those operations require an accepted credential and the ACLs they name.
|
||||
|
||||
Admin authorisation runs before request validation on every operation except [Queue bulk job](/admin-api/bulk-jobs/#queue-bulk-job), whose body is validated first because its ACL is selected from that body. An account lacking the required ACL is therefore refused with 403 even when its query string or body is also malformed.
|
||||
|
||||
@@ -60,12 +60,12 @@ A body with none of those fields resolves to no ACL at all and applies no change
|
||||
- `add_guild_members` maps to `bulk:add:guild_members`.
|
||||
- `schedule_user_deletion` maps to `bulk:delete:users`.
|
||||
|
||||
Fluxer bounds every grant separately. [Set user ACLs](/admin-api/users/#set-user-acls) and [Create Admin API key](/admin-api/api-keys/#create-admin-api-key) both refuse to write an ACL the acting Admin does not itself hold, with 403 `MISSING_ACL`. A wildcard holder is exempt. Set user ACLs also refuses the acting Admin's own account with 403 `ACCESS_DENIED`, and it resolves the target before the bound is evaluated, so an unknown ID fails first with 404 `UNKNOWN_USER`.
|
||||
Fluxer checks each ACL an Admin grants against the ACLs that Admin holds. [Set user ACLs](/admin-api/users/#set-user-acls) and [Create Admin API key](/admin-api/api-keys/#create-admin-api-key) both refuse to write an ACL the acting Admin does not itself hold, with 403 `MISSING_ACL`. A wildcard holder is exempt. Set user ACLs also refuses the acting Admin's own account with 403 `ACCESS_DENIED`, and it looks up the target account before it checks the granted ACLs, so an unknown ID fails first with 404 `UNKNOWN_USER`.
|
||||
|
||||
[Set user ACLs](/admin-api/users/#set-user-acls), [Create Admin API key](/admin-api/api-keys/#create-admin-api-key), and [Update Admin API key](/admin-api/api-keys/#update-admin-api-key) each accept at most 111 ACLs and validate every entry against the registry, so a value outside it returns 400 `INVALID_FORM_BODY`.
|
||||
|
||||
:::caution[`*` satisfies every present and future ACL]
|
||||
It lifts the per-key owner check and lifts the escalation bound on every grant its holder makes.
|
||||
A key whose owning account holds `*` skips the owner check. An Admin holding `*` can grant any ACL.
|
||||
:::
|
||||
|
||||
The [ACL registry](#acl-registry) below lists every value the instance recognises.
|
||||
@@ -80,7 +80,7 @@ A request refused by authentication, ACL evaluation, rate limiting, or body vali
|
||||
|
||||
Fluxer can record more than one entry for one request. [Update guild](/admin-api/guilds/#update-guild) records one entry for each mapped field group it applies. A queued bulk job records one summary entry when it finishes, with the creating Admin and that request's audit reason. The `update_user_flags`, `update_guild_features`, and `schedule_user_deletion` tasks also record one ordinary entry for each account or guild the worker changes, with `audit_log_reason` null.
|
||||
|
||||
An entry stores an acting Admin, a target type, a target ID, an action name, an optional audit reason, and a string metadata map. Fluxer resolves every other member at read time.
|
||||
An entry stores an acting Admin, a target type, a target ID, an action name, an optional audit reason, and a string metadata map. Fluxer fills every other field of the entry when the entry is read.
|
||||
|
||||
### Structure
|
||||
|
||||
@@ -239,7 +239,7 @@ An operation records one of the following actions. The field is a free-form stri
|
||||
| ban_file_sha | A file hash was added to the attachment blocklist |
|
||||
| ban_ip | An address was added to the IP blocklist |
|
||||
| ban_ip_skipped_exempt | An IP blocklist entry was declined by the instance exemption list |
|
||||
| ban_ip_skipped_cgnat | An IP blocklist entry was declined by the carrier-grade NAT blast-radius guard |
|
||||
| ban_ip_skipped_cgnat | An IP blocklist entry was declined because IP intelligence reports the address as a mobile carrier network |
|
||||
| ban_member | An account was banned from a guild |
|
||||
| ban_phrase | A phrase was added to the message phrase blocklist |
|
||||
| ban_profile_substring | A substring was added to the profile substring blocklist |
|
||||
@@ -276,7 +276,7 @@ An operation records one of the following actions. The field is a free-form stri
|
||||
| mark_suspicious_ip_skipped_invalid | The address could not be parsed, so no marker was written |
|
||||
| mark_suspicious_ip_skipped_ipinfo_unavailable | IP intelligence was unavailable, so no marker was written |
|
||||
| mark_suspicious_ip_skipped_trusted_commercial_privacy_provider | The address belongs to a trusted commercial privacy provider, so no marker was written |
|
||||
| mark_suspicious_ip_skipped_high_blast_radius | The address failed the blast-radius guard, so no marker was written |
|
||||
| mark_suspicious_ip_skipped_high_blast_radius | IP intelligence reports the address as a mobile, anycast, satellite, or education network, so no marker was written |
|
||||
| purge_asset | A stored media asset was purged |
|
||||
| purge_guild_emoji_asset | A guild emoji asset was purged |
|
||||
| purge_guild_sticker_asset | A guild sticker asset was purged |
|
||||
@@ -332,7 +332,7 @@ Send `X-Audit-Log-Reason` on operations that support an audit reason. Values are
|
||||
|
||||
Normalisation strips control and format characters and trims surrounding whitespace. An absent header, a blank value, and a value whose normalised length exceeds 512 characters all resolve to null. Fluxer never fails a request during this normalisation, so an over-long reason is dropped silently.
|
||||
|
||||
Fluxer writes the resolved value to `audit_log_reason` on every entry the request records. An operation that records no entry accepts the header and does nothing with it. An Admin resource page marks an operation with the audit reason capability only where an entry stores the value.
|
||||
Fluxer writes the resolved value to `audit_log_reason` on every entry the request records. An operation that records no entry accepts the header and does nothing with it. An Admin resource page shows the Audit reason label in an operation's route header only where an entry stores the value.
|
||||
|
||||
## Errors and rate limits
|
||||
|
||||
@@ -352,7 +352,7 @@ An Admin failure uses the same envelope as the [HTTP API error response](/http-a
|
||||
|
||||
<sup>2</sup> Present on an `INVALID_FORM_BODY` response, and on any other failure that has field detail
|
||||
|
||||
Structured detail sits at the top level of the object. A client MUST treat a member it does not recognise as absent, and MUST NOT assume that two failures with the same `code` have the same extra members.
|
||||
A failure with extra detail puts those extra members next to `code` and `message`, at the top level of the object. A client MUST treat a member it does not recognise as absent, and MUST NOT assume that two failures with the same `code` have the same extra members.
|
||||
|
||||
### Standard response statuses
|
||||
|
||||
@@ -376,7 +376,7 @@ Every Admin operation declares a route bucket, and each Admin resource page name
|
||||
|
||||
The global user bucket permits 50 requests per second, rising to 1,200 for an account that has the `HIGH_GLOBAL_RATE_LIMIT` flag. An account with the `RATE_LIMIT_BYPASS` flag is evaluated against no bucket at all. Instance configuration can change route and global limits as described in [rate limits](/topics/rate-limits/).
|
||||
|
||||
Fluxer classifies a successful Admin request from an ordinary account as a user request, and the response has no informational rate limit headers.
|
||||
A successful Admin request from an account that is not a bot has no informational rate limit headers.
|
||||
|
||||
## List ACLs
|
||||
|
||||
@@ -491,7 +491,7 @@ The registry is returned in this order by [List ACLs](#list-acls). A value outsi
|
||||
| system:heap_snapshot | Captures a heap snapshot of a running process |
|
||||
| user:cancel:bulk_message_deletion | Cancels the bulk message deletion an account scheduled for itself |
|
||||
| user:delete | Schedules or cancels account deletion |
|
||||
| user:disable:suspicious | Applies the suspicious account disable operation |
|
||||
| user:disable:suspicious | Disables a user for suspicious activity |
|
||||
| user:list:dm_channels | Reads a user's direct message and group direct message channels |
|
||||
| user:list:guilds | Reads a user's guild memberships |
|
||||
| user:list:relationships | Reads a user's relationships |
|
||||
@@ -572,7 +572,7 @@ Without an index, `q` is ignored, and a filtered page holds only the matches amo
|
||||
| logs | array[[Admin audit entry](#admin-audit-entry-object) object] | The entries in this page |
|
||||
| total<sup>1</sup> | integer | The number of entries the query matched |
|
||||
|
||||
<sup>1</sup> The index reports the full match count. The table fallback reports only the matches inside the rows it scanned
|
||||
<sup>1</sup> The index reports the full match count. Without the search index, the operation reports only the matches among the first `limit` plus `offset` entries
|
||||
|
||||
### Response
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ Missing settings use the defaults documented below. Invalid stored configuration
|
||||
| gateway_rollout | [Gateway rollout configuration](#gateway-rollout-configuration-object) object | Gateway admission and dispatch tuning |
|
||||
| voice_noise_suppression | [voice noise suppression configuration](#voice-noise-suppression-configuration-object) object | Client-side noise suppression rollout |
|
||||
| experiment_delivery | [experiment delivery configuration](#experiment-delivery-configuration-object) object | Cadence every client polls the experiments route on |
|
||||
| message_hover_tracking | [message hover tracking configuration](#message-hover-tracking-configuration-object) object | Message hover implementation rollout |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -92,7 +95,7 @@ Admission and dispatch tuning for the Gateway cluster.
|
||||
|
||||
Every field is present on read. An absent document or missing field uses the defaults above.
|
||||
|
||||
Admin requests use `rpc_request_timeout_ms`. The legacy stored name is covered in the [operator configuration reference](/operator/configuration/#stored-instance-policy).
|
||||
Admin reads and writes name this field `rpc_request_timeout_ms`. The legacy stored name is covered in the [operator configuration reference](/operator/configuration/#stored-instance-policy).
|
||||
|
||||
## Voice noise suppression configuration object
|
||||
|
||||
@@ -104,20 +107,20 @@ The instance rollout of client-side noise suppression. [Experiments](/http-api/e
|
||||
| --- | --- | --- |
|
||||
| enabled | boolean | Whether the rollout runs at all (default false) |
|
||||
| config_version | integer | Revision counter, raised by Fluxer and never accepted from a request |
|
||||
| default_backend | string | Backend given to a drawn account (default `standard`) |
|
||||
| default_backend | string | Backend given to an account the rollout selects (default `standard`) |
|
||||
| enabled_backends | array[string] | Backends a client MAY run, up to 7 entries (default every backend) |
|
||||
| allow_user_override | boolean | Whether an account's own choice replaces the assigned backend (default true) |
|
||||
| rollout_basis_points | integer | Share of accounts drawn, in basis points (0-10000, default 0) |
|
||||
| rollout_basis_points | integer | Share of accounts the rollout selects, in basis points (0-10000, default 0) |
|
||||
| rollout_salt | string | Salt of the sampling hash (1-64 characters, default `voice-ns-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) |
|
||||
| included_user_ids | array[snowflake] | Accounts the rollout always selects, up to 1000 entries (default empty) |
|
||||
| excluded_user_ids | array[snowflake] | Accounts the rollout never selects, up to 1000 entries (default empty) |
|
||||
| guild_overrides | array[[guild override](/http-api/experiments/#noise-suppression-guild-override-object) object] | Per-guild replacements, up to 200 entries (default empty) |
|
||||
| stereo_enabled | boolean | Whether a drawn client publishes a stereo microphone track (default false) |
|
||||
| stereo_enabled | boolean | Whether a client the rollout selects publishes a stereo microphone track (default false) |
|
||||
| suppression_strength | integer | Suppression strength (0-100, default 80) |
|
||||
|
||||
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 `default_backend` or `guild_overrides` entry naming a backend outside `enabled_backends` is dropped from what a client is served, and the stored value is kept as written.
|
||||
`excluded_user_ids` is applied before `included_user_ids`, so the rollout never selects an account in both. A `default_backend` or `guild_overrides` entry naming a backend outside `enabled_backends` is dropped from what a client is served, and the stored value is kept as written.
|
||||
|
||||
How often a client revalidates this rollout is not set here. It is set once for every experiment in the [experiment delivery configuration](#experiment-delivery-configuration-object) below.
|
||||
|
||||
@@ -130,11 +133,74 @@ How often a client polls [Get experiment assignments](/http-api/experiments/#get
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| poll_interval_seconds | integer | Seconds between client revalidations (60-86400, default 300) |
|
||||
| poll_jitter_percent | integer | Spread applied to each revalidation (0-50, default 15) |
|
||||
| poll_jitter_percent | integer | Maximum random change to each revalidation interval, as a percentage in either direction (0-50, default 15) |
|
||||
|
||||
Every field is present on read. An absent document or missing field uses the defaults above.
|
||||
|
||||
Both fields are served to every account, whether or not any experiment targets that account, and neither one is versioned by `config_version`. A client that has never reached the experiments route holds the same values as built-in defaults, 300 seconds and 15 percent, so neither field reaches a client that cannot read the route.
|
||||
Both fields are served to every account, whether or not any experiment targets that account, and neither one is versioned by `config_version`. A client that has never received a response from the experiments route uses built-in defaults of 300 seconds and 15 percent, which equal the defaults above. A client that cannot read the route never receives either field.
|
||||
|
||||
## Message hover tracking configuration object
|
||||
|
||||
The instance rollout of the message hover implementation the client runs in the message 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 `message-hover-tracking-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 resolves the hovered message from one shared pointer oracle, and a client that is not drawn keeps the per-row implementation 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.
|
||||
|
||||
## Message keyboard focus configuration object
|
||||
|
||||
The instance rollout of the keyboard navigation implementation the client runs in the message 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 `message-keyboard-focus-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 reaches the message list from the composer with one Tab and walks it with the arrow keys, and a client that is not drawn keeps the keyboard navigation 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.
|
||||
|
||||
## Blocked message groups configuration object
|
||||
|
||||
The instance rollout of how a client renders a revealed block of blocked messages. [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 `blocked-message-groups-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 draws a revealed block full width and spaces the message groups inside it, and a client that is not drawn keeps the rendering 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
|
||||
|
||||
@@ -169,7 +235,7 @@ A valid registration URL is accepted in every mode, including `closed`, and its
|
||||
|
||||
## Registration URL object
|
||||
|
||||
A registration URL is an invitation an Admin can issue while public registration is closed or gated. It has its own expiry, use budget, and approval requirement.
|
||||
A registration URL is an invitation an Admin can issue while the registration mode is `closed` or `approval`. It has its own expiry, use budget, and approval requirement.
|
||||
|
||||
Fluxer accepts a URL while it has no revocation time, has not passed its expiry, and has a use count below `max_uses`. A URL failing any of those tests is still reported here.
|
||||
|
||||
@@ -269,7 +335,7 @@ Community, direct message, premium and gating policy for the whole deployment.
|
||||
|
||||
<sup>2</sup> Each key is the operator override when one is set, and otherwise the matching `services_available` value
|
||||
|
||||
<sup>3</sup> `gif` and `youtube` report whether an API key resolves from the stored configuration or the deployment configuration. `bluesky` reports the integration's resolved enablement
|
||||
<sup>3</sup> `gif` and `youtube` report whether an API key resolves from the stored configuration or the deployment configuration. `bluesky` is the value of `integrations.bluesky.effective_enabled`
|
||||
|
||||
## Premium modes
|
||||
|
||||
@@ -280,7 +346,7 @@ Community, direct message, premium and gating policy for the whole deployment.
|
||||
|
||||
## Deferred phone gate object
|
||||
|
||||
A rule that requires phone verification in a configured window of hours after registration.
|
||||
A rule for accounts whose phone verification requirement was deferred. When such an account joins a guild within `window_hours` of registration, and the guild has the `DISCOVERABLE` feature or more than `member_threshold` members, Fluxer refuses the join until the account verifies a phone.
|
||||
|
||||
### Structure
|
||||
|
||||
@@ -373,7 +439,7 @@ Attachment retention overrides the operator has set, and the values in force.
|
||||
|
||||
<sup>1</sup> The override keys are `enabled`, `min_size_mb`, `max_size_mb`, `max_eligible_size_mb`, `min_lifetime_days`, `max_lifetime_days`, `curve`, `renew_threshold_days`, and `renew_window_days`. Each is null when the deployment default applies, and `effective` reports the value in force. `enabled` is a boolean, `curve` is a number from 0 to 1, the `_mb` keys are positive numbers, and the `_days` keys are positive safe integers
|
||||
|
||||
Conflicting maximum size or lifetime overrides are cleared and resolved from deployment defaults. The effective size range must have a finite maximum above its minimum, and retention must produce a valid expiry date. Check the returned `effective` values after an update.
|
||||
Fluxer clears `max_size_mb` when it is not above `min_size_mb`, clears `max_eligible_size_mb` when it is below `max_size_mb`, and clears `max_lifetime_days` when it is below `min_lifetime_days`. A cleared key uses the deployment default. The effective size range must have a finite maximum above its minimum, and retention must produce a valid expiry date. Check the returned `effective` values after an update.
|
||||
|
||||
## Branding asset kinds
|
||||
|
||||
@@ -472,7 +538,7 @@ One rule in that ordered set, with the filters that scope it and the limits it s
|
||||
| limits | map[string, integer] | Non-negative value for each [limit key](/http-api/instance/#limit-keys) the rule sets |
|
||||
| modifiedFields?<sup>1</sup> | array[string] | Limit keys whose value differs from the deployment default |
|
||||
|
||||
<sup>1</sup> Compared with the deployment default rule of the same identifier, or with the deployment's `default` rule for a custom identifier. A set key absent from that default counts as modified. A rule with no differing key omits the field. Explicit limit values remain unchanged
|
||||
<sup>1</sup> Compared with the deployment default rule of the same identifier, or with the deployment's `default` rule for a custom identifier. A set key absent from that default counts as modified. A rule with no differing key omits the field. Computing this field changes no value in `limits`
|
||||
|
||||
## Limit key metadata object
|
||||
|
||||
@@ -526,6 +592,9 @@ The body has one optional object for each section. Fluxer leaves an absent secti
|
||||
| gateway_rollout? | object | Any subset of the [Gateway rollout configuration](#gateway-rollout-configuration-object) fields, each bound as documented there |
|
||||
| voice_noise_suppression? | object | Any subset of the [noise suppression](#voice-noise-suppression-configuration-object) fields |
|
||||
| experiment_delivery? | object | Any subset of the [experiment delivery](#experiment-delivery-configuration-object) fields |
|
||||
| message_hover_tracking? | object | Any subset of the [message hover tracking](#message-hover-tracking-configuration-object) fields |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -538,12 +607,16 @@ The body has one optional object for each section. Fluxer leaves an absent secti
|
||||
|
||||
`voice_noise_suppression` takes every [voice noise suppression configuration](#voice-noise-suppression-configuration-object) field except `config_version`, each bound as documented there. Fluxer raises `config_version` by one on each request that supplies at least one of them. A section that is absent, or present with no field set, writes nothing and leaves `config_version` alone.
|
||||
|
||||
`message_hover_tracking` takes every [message hover tracking configuration](#message-hover-tracking-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.
|
||||
`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.
|
||||
|
||||
`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.
|
||||
|
||||
<sup>3</sup> A secret such as `klipy_api_key`, `api_key`, `hcaptcha_secret_key`, `turnstile_secret_key`, or the SMTP `password` is written when supplied and left alone when absent. `integrations.bluesky.keys` is the only way to write the Bluesky signing keys counted as `bluesky.key_count`. It takes up to 8 entries of `kid` (1-255 characters) and nullable `private_key` (up to 10000 characters), and replaces the stored key set outright
|
||||
|
||||
:::note[Single sign-on URL validation is conditional]
|
||||
Fluxer skips URL validation while the merged configuration leaves single sign-on disabled. A configuration both enabled and enforced fails validation against `sso` with `SSO_MISCONFIGURED` unless it resolves an authorisation endpoint, a token endpoint, a client identifier, and a claims source. `issuer` stands in for the endpoints and the claims source.
|
||||
Fluxer skips URL validation while the merged configuration leaves single sign-on disabled. A configuration both enabled and enforced fails validation against `sso` with `SSO_MISCONFIGURED` unless it resolves an authorisation endpoint, a token endpoint, a client identifier, and a claims source, which is `jwks_url` or `userinfo_url`. A set `issuer` meets the endpoint and claims source requirements, because Fluxer can discover them from the issuer.
|
||||
:::
|
||||
|
||||
#### Instance policy update structure
|
||||
@@ -558,11 +631,11 @@ Fluxer skips URL validation while the merged configuration leaves single sign-on
|
||||
| services? | object | Nullable `gif_enabled`, `youtube_enabled`, and `bluesky_enabled` overrides |
|
||||
| deferred_phone_gate?<sup>3</sup> | object | `enabled`, `window_hours`, and `member_threshold` |
|
||||
|
||||
<sup>1</sup> Setting `single_community_enabled` to true adopts the already designated guild when one still exists. When none is designated or the designated guild was deleted, it creates a community using `single_community_name` or the configured product name. A malformed designation or other datastore lookup error fails the operation instead of creating a replacement. On a deployment whose setup is already complete, enabling it while no guild is designated fails with 400 `INSTANCE_POLICY_TRANSITION_NOT_ALLOWED`, as does enabling it when the acting Admin account cannot be resolved. Setting it to false only clears the flag and leaves the guild in place
|
||||
<sup>1</sup> Setting `single_community_enabled` to true adopts the already designated guild when one still exists. When none is designated or the designated guild was deleted, it creates a community using `single_community_name` or the configured product name. When the stored guild ID is not a valid ID, or the guild lookup fails for a reason other than an unknown guild, the operation fails and Fluxer creates no community. On a deployment whose setup is already complete, enabling it while no guild is designated fails with 400 `INSTANCE_POLICY_TRANSITION_NOT_ALLOWED`, as does enabling it when the acting Admin account cannot be resolved. Setting it to false only clears the flag and leaves the guild in place
|
||||
|
||||
<sup>2</sup> The setting can be changed only while `direct_messages_locked` is false, and a change attempted after the lock is set fails with 400 `INSTANCE_POLICY_TRANSITION_NOT_ALLOWED` unless the same request sets `direct_messages_locked` to false. Re-enabling direct messages sets the lock again
|
||||
|
||||
<sup>3</sup> `window_hours` is a positive number up to 8760 and `member_threshold` is a positive integer up to 1000000. Each key is applied on its own
|
||||
<sup>3</sup> `window_hours` is a positive number up to 8760 and `member_threshold` is a positive integer up to 1000000. An omitted key keeps its stored value
|
||||
|
||||
`direct_messages_locked` accepts only false, and a body that sets it to true fails with 400 `INVALID_FORM_BODY`.
|
||||
|
||||
@@ -574,12 +647,12 @@ 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`, `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`, `message_hover_tracking`, `message_keyboard_focus`, `blocked_message_groups`, `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
|
||||
|
||||
Gateway rollout changes apply across the cluster. Premium mode changes affect the limits in force without replacing the saved limit configuration. On self-hosted deployments, `everyone` hides premium-filtered rules. Switching back to `mirror` restores them unless an Admin has replaced the limit configuration in the meantime. Enabling single community mode creates the community when none is designated, with the acting Admin as owner.
|
||||
Fluxer publishes a `gateway_rollout` change to the Gateway cluster. Premium mode changes affect the limits in force without replacing the saved limit configuration. On self-hosted deployments, `everyone` hides premium-filtered rules. Switching back to `mirror` restores them unless an Admin has replaced the limit configuration in the meantime. Enabling single community mode creates the community when none is designated, with the acting Admin as owner.
|
||||
|
||||
Initial setup completes on the first update that sets `app_public.setup.configured` to true from a session credential whose account holds neither `admin:authenticate` nor the wildcard. That update grants the account the wildcard Admin ACL and marks the deployment as bootstrapped.
|
||||
|
||||
@@ -661,10 +734,10 @@ No configuration is written and the supplied credentials are discarded when the
|
||||
|
||||
<RouteHeader method="POST" path="/v1/admin/instance/registration-urls" />
|
||||
|
||||
Issues a registration URL an Admin can hand out while public registration is closed or gated, and returns a [registration URL creation](#registration-url-creation-object) object. Requires `instance:config:update`.
|
||||
Issues a registration URL an Admin can hand out while the registration mode is `closed` or `approval`, and returns a [registration URL creation](#registration-url-creation-object) object. Requires `instance:config:update`.
|
||||
|
||||
:::caution[The code is the identifier]
|
||||
The returned `code` is the same value as `registration_url.id`, and every [Get instance configuration](#get-instance-configuration) response has that identifier. Treat `instance:config:view` as a registration capability on a gated deployment.
|
||||
The returned `code` is the same value as `registration_url.id`, and every [Get instance configuration](#get-instance-configuration) response has that identifier. Treat `instance:config:view` as a registration capability on a deployment in `closed` or `approval` mode.
|
||||
:::
|
||||
|
||||
### JSON body
|
||||
@@ -758,7 +831,7 @@ Both decisions remove the pending registration, but the endpoint does not requir
|
||||
|
||||
Approval removes both the `registration_pending_approval` trait and the `registration_rejected` trait, and joins the account to the single community when that mode is enabled and a guild is designated. A join that fails is logged and does not fail the request. Rejection removes the `registration_pending_approval` trait and adds the `registration_rejected` trait, which blocks login and every later session creation. A session issued before the decision stays valid. The pending registration is removed either way.
|
||||
|
||||
Invalid pending-registration data prevents the decision. A later failure can still leave account changes applied, so check the account and pending list before retrying.
|
||||
When the stored pending registration list fails validation, the request fails before Fluxer changes the account. A failure after Fluxer writes the account traits leaves those traits written and can leave the entry in the pending list. Check the account traits and the pending list before retrying.
|
||||
|
||||
One Admin audit entry with the action `approve_registration` or `reject_registration` targets the account, records the audit reason, and has no metadata.
|
||||
|
||||
@@ -793,7 +866,7 @@ The response reflects the configuration in force on the node that serves the req
|
||||
Replaces the stored limit configuration with the supplied document and returns the resulting [limit configuration response](#limit-configuration-response-object) object. Requires `instance:limit_config:update`.
|
||||
|
||||
:::caution[An absent custom rule is removed]
|
||||
A rule whose identifier matches a deployment default rule survives. Fluxer re-merges the write with the current defaults immediately.
|
||||
A default rule that the body omits is restored from the deployment defaults. Fluxer re-merges the write with the current defaults immediately.
|
||||
:::
|
||||
|
||||
:::note[The stored document can differ from the body]
|
||||
|
||||
@@ -11,7 +11,7 @@ These routes list recorded background jobs and request cancellation. Create jobs
|
||||
Reads require `jobs:view` and cancellation requires `jobs:cancel`. Every operation on this page shares the `admin:jobs:view` bucket.
|
||||
|
||||
:::note[Job updates vary by task]
|
||||
Not all background work appears here, and recorded status, progress and attempts may not reflect every execution. Use the [archive routes](/admin-api/archives/) to track archive progress and failures.
|
||||
Mention processing, link previews, and every scheduled task except `syncDisposableEmailDomains`, `syncUrlBlocklists` and `syncFileShaBlocklists` run with no job record and never appear here. A job also runs with no record when Fluxer fails to write that record. When a later write of status, progress or attempts fails, Fluxer logs the failure and the job continues, so the stored values can be behind the real run. Use the [archive routes](/admin-api/archives/) to track archive progress and failures.
|
||||
:::
|
||||
|
||||
## Admin job object
|
||||
@@ -37,14 +37,14 @@ One object describes one recorded job. These routes can only change `cancel_requ
|
||||
| jet_stream_lane | ?string | The assigned [processing lane](#processing-lanes), or null |
|
||||
| jet_stream_seq | ?string | The recorded queue sequence as a decimal string, or null |
|
||||
| attempts | integer | The recorded retry count, not a total of all executions |
|
||||
| max_attempts | integer | The recorded attempt limit. Actual retry behaviour may differ |
|
||||
| max_attempts | integer | Attempt limit recorded at queue time. Retries stop at the lane's delivery limit, which can differ from this value |
|
||||
| run_at | ?ISO8601 timestamp | The earliest permitted run time, or null for an immediate job |
|
||||
| cancel_requested | boolean | Whether cancellation has been requested |
|
||||
| context_link | ?string | An Admin console path related to the job, or null |
|
||||
| payload | ?string | The JSON-encoded job input, or null |
|
||||
| result | ?string | The JSON-encoded result, or null |
|
||||
|
||||
Progress fields may remain null. The payload can contain identifiers and message content, including in list responses.
|
||||
Progress fields stay null until the task reports progress. A task can report progress with no total or no message, which sets `progress_total` or `progress_message` to null. The payload can contain identifiers and message content, including in list responses.
|
||||
|
||||
### Example
|
||||
|
||||
@@ -103,7 +103,7 @@ Pass all three fields from `next_cursor` back to [List jobs](#list-jobs) using t
|
||||
| queued | The job was recorded and no later status has been recorded |
|
||||
| running | The job was recorded as started |
|
||||
| succeeded | The job was recorded as completed successfully |
|
||||
| cancelled | The task honoured a cancellation request |
|
||||
| cancelled | The task read the cancellation request and stopped |
|
||||
| deadletter | The job was recorded as failed, including a failure to queue it |
|
||||
|
||||
`queued` and `running` accept cancellation requests. The other statuses are terminal. A cancellation request does not guarantee that the task will stop.
|
||||
@@ -170,7 +170,7 @@ Supply all three cursor parameters together or omit all three. An incomplete or
|
||||
|
||||
Returns recorded active jobs as [Admin job](#admin-job-object) objects. Requires `jobs:view`.
|
||||
|
||||
This operation has no filters or pagination. Recorded state can lag execution.
|
||||
This operation has no filters or pagination. The stored status and progress can be behind the work the task has already done.
|
||||
|
||||
### Response body
|
||||
|
||||
@@ -247,7 +247,7 @@ Returns false for a missing or terminal job. Repeating a request for a queued or
|
||||
|
||||
### Side effects
|
||||
|
||||
Sets `cancel_requested` to true for a queued or running job. Only tasks that support cancellation will stop, and completed work is not undone. A status of `cancelled` confirms that the task honoured the request.
|
||||
Sets `cancel_requested` to true for a queued or running job. `sendSystemDm`, `syncDisposableEmailDomains`, `bulkBanFileShas`, `bulkDeleteMessagesForUsers`, and the tasks queued by [Bulk jobs](/admin-api/bulk-jobs/) check for the request while they run and stop at the next check. Other tasks run to the end. Completed work is not undone. A status of `cancelled` means the task stopped because of the request.
|
||||
|
||||
### Rate limit
|
||||
|
||||
|
||||
@@ -285,7 +285,7 @@ One [Admin audit entry](/admin-api/#admin-audit-entry-object) is recorded with t
|
||||
|
||||
Searches the message index of one channel, or resolves one message in that channel by message ID or by attachment identity. Requires `message:lookup`.
|
||||
|
||||
The modes return a search body or a lookup body, and the query decides which one arrives.
|
||||
A request with `message_id` or `attachment_id` returns the lookup response body. Any other request returns the search response body.
|
||||
|
||||
### Query parameters
|
||||
|
||||
@@ -433,7 +433,7 @@ Fluxer resolves the attachment from the live message when that message still exi
|
||||
|
||||
The attachment is `submitting` while the request runs, then `submitted` with its report ID on success. A failed submission attempts to retract the opened report, marks the attachment `failed` with the raw reason, and returns `NCMEC_SUBMISSION_FAILED`. A failed retraction is logged without changing that recorded state.
|
||||
|
||||
When the resolved attachment has an author who has not already been enforced against, Fluxer sets the account's deleted and disabled flags, clears any temporary ban, and records the deletion reason for child sexual content. It then schedules deletion 60 days ahead, deletes every authentication session, propagates the resulting user update, and triggers a user archive for that account.
|
||||
When the resolved attachment has an author whose account an earlier NCMEC submission has not already disabled, Fluxer sets the account's deleted and disabled flags, clears any temporary ban, and records the deletion reason for child sexual content. It then schedules deletion 60 days ahead, deletes every authentication session, sends [User Update](/gateway/events/#user-update) to that account, and triggers a user archive for that account. When the account's public user fields change, Fluxer also sends [Guild Member Update](/gateway/events/#guild-member-update) to each guild the account is in.
|
||||
|
||||
Message content is deleted only after that archive completes. The archive is re-checked every 15 seconds by default, for at most 240 attempts. Once the archive is complete the reported message is deleted, its attachments are purged, and [Message Delete](/gateway/events/#message-delete) is sent.
|
||||
|
||||
|
||||
@@ -285,7 +285,7 @@ An instance with no search backend returns 403 `FEATURE_TEMPORARILY_DISABLED` on
|
||||
|
||||
<sup>1</sup> Matched against `category`, `additional_info`, `reported_guild_name`, and `reported_channel_name` only. Omitting it matches every report satisfying the remaining filters
|
||||
|
||||
<sup>2</sup> The route accepts it, and it alone selects the search branch. It narrows nothing
|
||||
<sup>2</sup> The route accepts it, and supplying it with no other filter still selects the search branch. The search ignores its value and returns matching reports from every channel
|
||||
|
||||
<sup>3</sup> `created_at` orders by the time encoded in the report ID, and `reported_at` by the submission time stored on the report
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ Progress for one queued rebuild. The object shape is selected by `status`.
|
||||
|
||||
<sup>1</sup> `total` is the same value as `indexed` while the rebuild runs and becomes the real total on completion. The `discovery` rebuild reports the approved listing count from its first batch onwards
|
||||
|
||||
<sup>2</sup> The unit is the document the handler writes. A `channel_messages` rebuild counts the channels it queued
|
||||
<sup>2</sup> Each document the rebuild writes counts as one. A `channel_messages` rebuild counts the channels it queued
|
||||
|
||||
<sup>3</sup> Rewritten on every progress report, so its value moves forward while the rebuild runs
|
||||
|
||||
@@ -120,8 +120,8 @@ Every field is optional. Fluxer reads an absent or empty body as an empty object
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| guild_id?<sup>1</sup> | snowflake | The ID of the guild whose copy of the index is rebuilt |
|
||||
| user_id?<sup>2</sup> | snowflake | The ID of the user whose copy of the index is rebuilt |
|
||||
| guild_id?<sup>1</sup> | snowflake | The ID of the guild whose documents in the index are rebuilt |
|
||||
| user_id?<sup>2</sup> | snowflake | The ID of the user whose documents in the index are rebuilt |
|
||||
|
||||
<sup>1</sup> Required by `channel_messages` and `guild_members`. Every other index name ignores it
|
||||
|
||||
@@ -168,7 +168,7 @@ Returns the [search index refresh progress](#search-index-refresh-progress-objec
|
||||
| --- | --- | --- |
|
||||
| job_id<sup>1</sup> | string | The identifier returned by [Refresh search index](#refresh-search-index) |
|
||||
|
||||
<sup>1</sup> The value is bounded at 1 to 128 characters after normalisation and need not be a snowflake
|
||||
<sup>1</sup> The value is bounded at 1 to 128 characters after Fluxer removes every form feed (U+000C) and right-to-left override (U+202E) character and trims whitespace from both ends. It need not be a snowflake
|
||||
|
||||
### Response
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ Filter [List jobs](/admin-api/jobs/#list-jobs) by the `sendSystemDm` task type t
|
||||
|
||||
For each recipient the job opens the direct message channel between the system account and that recipient, then sends the message. [Channel Create](/gateway/events/#channel-create) reaches a recipient only when the channel is newly created or was closed on that recipient's side. A recipient who already has the channel open observes only [Message Create](/gateway/events/#message-create).
|
||||
|
||||
Recipients need not have interacted with the system account before. Broadcasts bypass normal direct message spam restrictions.
|
||||
Recipients need not have interacted with the system account before. Fluxer skips the direct message permission checks for the system account. A block, no friendship, no mutual guild, and the recipient's direct message privacy settings do not stop delivery.
|
||||
|
||||
Cancellation stops remaining deliveries and leaves sent messages in place. The [job](/admin-api/jobs/#admin-job-object) then reports `cancelled`.
|
||||
|
||||
|
||||
@@ -13,14 +13,14 @@ Each mutable field group has its own route, its own [ACL](/admin-api/#acl-regist
|
||||
A read records no audit entry and reads no audit reason. [List user sessions](#list-user-sessions) and [List user WebAuthn credentials](#list-user-webauthn-credentials) are the exceptions. `POST /v1/admin/users/{user_id}/avatar-block` addresses a user path but belongs to [Blocklists](/admin-api/blocklists/#block-a-users-current-avatar).
|
||||
|
||||
:::caution[A missing PII ACL nulls the protected fields]
|
||||
The read still succeeds, so a null field is no evidence that the account has none.
|
||||
A caller without `user:view:email` receives a null `email`. A caller without `user:view:dob` receives a null `date_of_birth`, and a caller without `user:view:ip` receives null `last_active_ip`, `last_active_ip_reverse`, and `last_active_location`. The read still succeeds, so a null field is no evidence that the account has none.
|
||||
:::
|
||||
|
||||
## Admin user object
|
||||
|
||||
The complete administrative view of one account. It has every stored flag, the private lifecycle fields, and the contact and network fields that the public [user object](/http-api/users/#user-object) never exposes.
|
||||
|
||||
When the caller lacks the matching ACL, Fluxer redacts field groups and still returns every key, so the object shape is identical for every caller.
|
||||
When the caller lacks `user:view:email`, `user:view:dob`, or `user:view:ip`, Fluxer redacts the fields that the missing ACL protects and still returns every key, so the object shape is identical for every caller.
|
||||
|
||||
### Structure
|
||||
|
||||
@@ -294,7 +294,7 @@ One entry for each authentication session of an account. Terminated sessions rem
|
||||
|
||||
## Admin resolved user object
|
||||
|
||||
The account summary the Admin direct message channel and relationship objects embed. It has `avatar` on top of the [Admin user summary](/admin-api/#admin-user-summary-object) that audit entries embed.
|
||||
The account summary embedded in the Admin direct message channel object and the Admin relationship object. It is the [Admin user summary](/admin-api/#admin-user-summary-object) that audit entries embed, with `avatar` added.
|
||||
|
||||
### Structure
|
||||
|
||||
@@ -395,14 +395,14 @@ One entry for each direct message or group direct message channel the account ha
|
||||
|
||||
## Admin user change log object
|
||||
|
||||
One recorded change to an identity or contact field. The account holder's own changes appear here, and so do [Change user username](#change-user-username) and [Change user email](#change-user-email).
|
||||
One recorded change to the account's email address, phone verification state, or username and discriminator. The account holder's own changes appear here, and so do [Change user username](#change-user-username) and [Change user email](#change-user-email).
|
||||
|
||||
### Structure
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| event_id<sup>1</sup> | string | The ID of the change entry as an unsigned 64-bit decimal string |
|
||||
| field | string | The name of the identity or contact field that changed |
|
||||
| field | string | The field that changed, one of `email`, `has_verified_phone`, or `fluxer_tag` |
|
||||
| old_value<sup>2</sup> | ?string | The value before the change, or null when the field was unset |
|
||||
| new_value<sup>2</sup> | ?string | The value after the change, or null when the field was cleared |
|
||||
| reason<sup>3</sup> | ?string | The recorded reason for the change, or null when unrecorded |
|
||||
@@ -571,7 +571,7 @@ An account with a pending or completed deletion is still returned, with its life
|
||||
|
||||
Replaces the username, allocates or claims a discriminator, and returns the resulting account. Requires `user:update:username`.
|
||||
|
||||
A target account may hold a custom discriminator on every self-hosted instance, and on any other instance only when the `feature_custom_discriminator` limit admits it.
|
||||
A target account may hold a custom discriminator on every self-hosted instance, and on any other instance only when the `feature_custom_discriminator` limit resolves to a value above zero for that account.
|
||||
|
||||
### Path parameters
|
||||
|
||||
@@ -694,11 +694,11 @@ The operation accepts no request body and never clears verification, so the one
|
||||
| 200<sup>1</sup> | response body | Email address was marked verified |
|
||||
| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
|
||||
|
||||
<sup>1</sup> An account that has no stored email address is still accepted, and the verified marker is written against the absent address
|
||||
<sup>1</sup> An account that has no stored email address is still accepted, and `email_verified` is set to true while `email` stays null
|
||||
|
||||
### Side effects
|
||||
|
||||
`email_verified` becomes true and `email_bounced` becomes false. Every email-clearable [suspicious activity flag](#suspicious-activity-flags) is cleared from the account in the same write.
|
||||
`email_verified` becomes true and `email_bounced` becomes false. The same write clears each of these [suspicious activity flag](#suspicious-activity-flags) bits: `REQUIRE_VERIFIED_EMAIL`, `REQUIRE_REVERIFIED_EMAIL`, `REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE`, `REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE`, `REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE`, and `REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE`.
|
||||
|
||||
[User Update](/gateway/events/#user-update) is emitted to the account's own sessions. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `verify_email`, target type `user`, and a metadata key `email` with the address as it stood before the write, or the literal `null` when the account had none.
|
||||
|
||||
@@ -713,7 +713,7 @@ The operation accepts no request body and never clears verification, so the one
|
||||
Requests a new verification email for the account. Requires `user:update:email`. Returns an empty 204 response.
|
||||
|
||||
:::caution[An already verified account gets no email]
|
||||
An already verified account with no email reverification [suspicious activity flag](#suspicious-activity-flags) returns 204 without storing a token or sending anything.
|
||||
When the account's email is already verified and none of the [suspicious activity flag](#suspicious-activity-flags) bits `REQUIRE_REVERIFIED_EMAIL`, `REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE`, `REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE`, or `REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE` is set, Fluxer returns 204 without storing a token or sending anything.
|
||||
:::
|
||||
|
||||
:::note[Delivery is dropped silently]
|
||||
@@ -775,7 +775,7 @@ Delivery is silently dropped when the instance email transport is disabled and w
|
||||
|
||||
<sup>1</sup> A missing address returns `INVALID_FORM_BODY` with the validation code `USER_DOES_NOT_HAVE_AN_EMAIL_ADDRESS`
|
||||
|
||||
Unlike [Resend verification email](#resend-verification-email), this operation has no per-address control and does not refuse a bot account.
|
||||
Unlike [Resend verification email](#resend-verification-email), this operation has no limit of three emails per address in fifteen minutes, and it accepts a bot account.
|
||||
|
||||
### Side effects
|
||||
|
||||
@@ -869,7 +869,7 @@ Clearing is the only profile change on this resource. No route sets a biography,
|
||||
|
||||
Clearing `avatar` or `banner` schedules the previous asset for deletion.
|
||||
|
||||
[User Update](/gateway/events/#user-update) is emitted to the account's own sessions, and each guild the account is a member of receives [Guild Member Update](/gateway/events/#guild-member-update) when a partial user field changed, which covers `avatar` and `global_name`.
|
||||
[User Update](/gateway/events/#user-update) is emitted to the account's own sessions, and each guild the account is a member of receives [Guild Member Update](/gateway/events/#guild-member-update) when the operation changes `avatar` or `global_name`.
|
||||
|
||||
The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `clear_fields`, target type `user`, and a metadata key `fields` with the submitted names joined by commas.
|
||||
|
||||
@@ -915,7 +915,7 @@ Marks the account as a bot or as an ordinary account, and returns the resulting
|
||||
|
||||
### Side effects
|
||||
|
||||
[User Update](/gateway/events/#user-update) is emitted to the account's own sessions, and each guild the account is a member of receives [Guild Member Update](/gateway/events/#guild-member-update) when a partial user field changed.
|
||||
[User Update](/gateway/events/#user-update) is emitted to the account's own sessions, and each guild the account is a member of receives [Guild Member Update](/gateway/events/#guild-member-update) when the write changes `bot` or `system`.
|
||||
|
||||
The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `set_bot_status`, target type `user`, and a metadata key `bot`. Clearing `system` as a side effect records no second entry.
|
||||
|
||||
@@ -1108,7 +1108,7 @@ Additions are applied before removals, so a flag named in both arrays ends up cl
|
||||
|
||||
### Side effects
|
||||
|
||||
[User Update](/gateway/events/#user-update) is emitted to the account's own sessions. A change to publicly visible user fields also sends [Guild Member Update](/gateway/events/#guild-member-update) to the account's guilds.
|
||||
[User Update](/gateway/events/#user-update) is emitted to the account's own sessions. A change to the account's public flags also sends [Guild Member Update](/gateway/events/#guild-member-update) to the account's guilds.
|
||||
|
||||
The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `update_flags`, target type `user`, and the metadata keys `add_flags`, `remove_flags`, and `new_flags`. An empty array is omitted from the metadata map.
|
||||
|
||||
@@ -1168,7 +1168,7 @@ The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-obje
|
||||
|
||||
Sets whether the account is treated as having completed phone verification, and returns the resulting account. Requires `user:update:phone`.
|
||||
|
||||
The user-facing phone verification marker is otherwise irreversible, and this is the one operation that clears it.
|
||||
This operation is the one way to set `has_verified_phone` back to false once it is true.
|
||||
|
||||
### Path parameters
|
||||
|
||||
@@ -1242,7 +1242,7 @@ The operation sets verification requirements without disabling the account. [Dis
|
||||
| 200 | response body | Flags were replaced |
|
||||
| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
|
||||
|
||||
The stored deferral bit `1 << 16` is preserved only when it was already set and the submitted value has the same non-zero deferrable phone bits as the stored value. Any other submitted value clears the deferral, so the requirement takes effect immediately.
|
||||
The stored deferral bit `1 << 16` is preserved only when it was already set and the submitted value sets the same `REQUIRE_VERIFIED_PHONE` and `REQUIRE_REVERIFIED_PHONE` bits as the stored value, with at least one of them set. Any other submitted value clears the deferral bit, so any phone requirement in the submitted value stops waiting for a guild join.
|
||||
|
||||
[Bulk jobs](/admin-api/bulk-jobs/) applies the same change to up to 1,000 accounts as a queued `update_suspicious_activity_flags` task.
|
||||
|
||||
@@ -1355,7 +1355,7 @@ Every authentication session of the account is deleted. [Unban user](#unban-user
|
||||
|
||||
`DISABLED` is added to the account flags and `temp_banned_until` is set to the resolved expiry. Every authentication session is then deleted, so the account is signed out on every device.
|
||||
|
||||
An authentication attempt while the ban stands fails with 403 `ACCOUNT_SUSPENDED_TEMPORARILY`. An attempt after the expiry has passed clears the disabled state and `temp_banned_until` in the same request, so a temporary ban ends without an Admin operation.
|
||||
An authentication attempt while the ban stands fails with 403 `ACCOUNT_SUSPENDED_TEMPORARILY`. An attempt after the expiry has passed clears the `DISABLED` flag and `temp_banned_until` in the same request, so a temporary ban ends without an Admin operation.
|
||||
|
||||
When the account has an email address and `duration_hours` is greater than zero, the account holder is emailed the duration, the expiry, and the supplied `reason`. A permanent ban sends no email.
|
||||
|
||||
@@ -1454,19 +1454,19 @@ The `X-Audit-Log-Reason` value is also stored on the account as the private dele
|
||||
| 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist |
|
||||
| 409 | [error response](/admin-api/#error-response) | `CONFLICT`, because erasure has started or the deletion state changed during the request |
|
||||
|
||||
The permission also allows scheduling deletion of the acting Admin or an account with broader permissions.
|
||||
`user:delete` also allows scheduling deletion of the acting Admin or an account with broader permissions.
|
||||
|
||||
### Side effects
|
||||
|
||||
The account can no longer authenticate, and its existing authentication sessions are deleted. Erasure is scheduled for the resulting deadline.
|
||||
|
||||
Fluxer cancels a Stripe subscription on the account without proration and refunds the charge behind its latest invoice as fraudulent. A failure in that path is logged, and the deletion still applies.
|
||||
Fluxer cancels a Stripe subscription on the account without proration and refunds the charge behind its latest invoice as fraudulent. When the cancellation or refund fails, Fluxer logs the failure and still keeps the deletion schedule.
|
||||
|
||||
The account holder is emailed the deadline and the supplied `public_reason` when the account has an email address.
|
||||
|
||||
Reasons other than `USER_REQUESTED` also trigger email and IP blocking and resolution of pending reports. These enforcement steps are best-effort and can fail without cancelling the deletion schedule.
|
||||
For every reason other than `USER_REQUESTED`, Fluxer also blocks the account's email address. It marks the account's last active IP address, its authorised IP addresses, and the IP addresses of its active and terminated sessions as suspicious, and it resolves the pending reports against the account. These enforcement steps are best-effort and can fail without cancelling the deletion schedule.
|
||||
|
||||
[User Update](/gateway/events/#user-update) is emitted after the sessions have already been deleted. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `schedule_deletion`, target type `user`, and the metadata keys `days` and `reason_code`. The identifier bans record their own blocklist entries, and a non-zero report pass records a second entry with action `auto_resolve_reports_on_deletion` and a metadata key `resolved_count`.
|
||||
[User Update](/gateway/events/#user-update) is emitted after the sessions have already been deleted. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `schedule_deletion`, target type `user`, and the metadata keys `days` and `reason_code`. The identifier bans record their own blocklist entries, and when Fluxer resolves at least one report, it records a second entry with action `auto_resolve_reports_on_deletion` and a metadata key `resolved_count`.
|
||||
|
||||
### Rate limit
|
||||
|
||||
@@ -1705,7 +1705,7 @@ Removes every relationship of the account in one [relationship category](#relati
|
||||
| --- | --- | --- |
|
||||
| removed_count<sup>1</sup> | integer | The number of relationships that were removed |
|
||||
|
||||
<sup>1</sup> Counted from the target account's perspective, so a mirrored friendship contributes one
|
||||
<sup>1</sup> Counted from the target account's perspective, so a friendship, which both accounts store, contributes one
|
||||
|
||||
### Response
|
||||
|
||||
@@ -1722,7 +1722,7 @@ Removals run one at a time, so a failure partway through leaves the earlier remo
|
||||
|
||||
Friendships and friend requests are removed for both accounts. Blocks are removed only for the target account.
|
||||
|
||||
Both parties of a mirrored removal receive [Relationship Remove](/gateway/events/#relationship-remove) naming the other account. A `blocked` removal dispatches only to the target account, and ordinary delivery from the unblocked account resumes.
|
||||
When a friendship or friend request is removed, both accounts receive [Relationship Remove](/gateway/events/#relationship-remove) naming the other account. A `blocked` removal dispatches only to the target account, and ordinary delivery from the unblocked account resumes.
|
||||
|
||||
The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `remove_relationships_by_category`, target type `user`, and the metadata keys `category` and `removed_count`.
|
||||
|
||||
@@ -1764,7 +1764,7 @@ The operation addresses one category, so an account that is both a former friend
|
||||
|
||||
A friendship or friend request is removed for both accounts. A block is removed only for the owning account.
|
||||
|
||||
Both parties of a mirrored removal receive [Relationship Remove](/gateway/events/#relationship-remove) naming the other account. A `blocked` removal dispatches only to the owning account.
|
||||
When a friendship or friend request is removed, both accounts receive [Relationship Remove](/gateway/events/#relationship-remove) naming the other account. A `blocked` removal dispatches only to the owning account.
|
||||
|
||||
The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `remove_relationship`, target type `user`, and the metadata keys `target_user_id` and `category`.
|
||||
|
||||
@@ -1848,7 +1848,7 @@ There is no operation that revokes one session. The account's Admin API keys, bo
|
||||
| terminated_count | integer | The number of sessions that were terminated |
|
||||
|
||||
:::caution[A terminated session cannot be restored]
|
||||
The tombstone stays visible through [List user sessions](#list-user-sessions), but the session itself is gone.
|
||||
[List user sessions](#list-user-sessions) still lists the terminated session with `deleted_at` set. The session no longer authenticates any request, and no operation reactivates it.
|
||||
:::
|
||||
|
||||
### Side effects
|
||||
@@ -1922,7 +1922,7 @@ The passkey or security key stops authenticating immediately and cannot be resto
|
||||
|
||||
### Side effects
|
||||
|
||||
The credential record is deleted. When it was the account's final WebAuthn credential, Fluxer removes the `WEBAUTHN` [authenticator type](/http-api/users/#authenticator-types) from the account, emits [User Update](/gateway/events/#user-update) to the account's own sessions, and resyncs the authenticator mirror of every bot the account owns.
|
||||
The credential record is deleted. When it was the account's final WebAuthn credential, Fluxer removes the `WEBAUTHN` [authenticator type](/http-api/users/#authenticator-types) from the account, emits [User Update](/gateway/events/#user-update) to the account's own sessions, and copies the account's authenticator types onto the bot user of every application the account owns.
|
||||
|
||||
[WebAuthn Credentials Update](/gateway/events/#webauthn-credentials-update) is emitted to the target account with its remaining credentials.
|
||||
|
||||
@@ -1957,7 +1957,7 @@ Clearing the authenticator type set removes `WEBAUTHN` from the advertised authe
|
||||
|
||||
### Side effects
|
||||
|
||||
The account's TOTP secret, authenticator type set, and every multi-factor backup code are deleted. Fluxer resyncs the authenticator mirror of every bot the account owns. Sessions and credentials are not revoked.
|
||||
The account's TOTP secret, authenticator type set, and every multi-factor backup code are deleted. Fluxer clears the authenticator types of the bot user of every application the account owns. Sessions and credentials are not revoked.
|
||||
|
||||
[User Update](/gateway/events/#user-update) is emitted to the account's own sessions. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `disable_mfa`, target type `user`, and no metadata.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ A voice region is a named group of media machines, and a voice server is one mac
|
||||
No operation here addresses a live session, a participant, or a track. [Get voice state counts](/admin-api/gateway/#get-voice-state-counts) reports occupancy, and [List RTC regions](/http-api/channels/#list-rtc-regions) is the caller-facing view of the same regions.
|
||||
|
||||
:::note[Changes apply to new placements]
|
||||
Changes take effect across the instance after a short delay. No write moves or disconnects an existing session.
|
||||
Every write publishes a change notification. Each API node reloads the voice configuration when it receives that notification, a short time after the write. Placements after that reload use the new configuration. No write moves or disconnects an existing session.
|
||||
:::
|
||||
|
||||
:::caution[A failed response can follow a successful change]
|
||||
@@ -64,7 +64,7 @@ The operator supplies `id` on creation. Channels reference it through `rtc_regio
|
||||
| updated_at | ?ISO8601 timestamp | Time the region record last changed, or null when the stored row has none |
|
||||
| servers?<sup>4</sup> | array[[Admin voice server](#admin-voice-server-object) object] | The servers registered in the region |
|
||||
|
||||
<sup>1</sup> The flag is not exclusive, and setting it on a second region does not clear it on the first. Each node then takes the first flagged region its reload lists as the default, or the first region its reload lists when no region is flagged
|
||||
<sup>1</sup> The flag is not exclusive, and setting it on a second region does not clear it on the first. Each API node then uses the first flagged region in the region list it loaded as the default. When no region is flagged, it uses the first region in that list
|
||||
|
||||
<sup>2</sup> Setting any of these three makes the region unusable for a private call
|
||||
|
||||
@@ -93,7 +93,7 @@ The operator supplies `id` on creation. Channels reference it through `rtc_regio
|
||||
|
||||
## Admin voice server object
|
||||
|
||||
A server record names one LiveKit deployment, the API key pair the instance authenticates to it with, and its own copy of the eligibility fields. A server is reachable for placement only when its region is also reachable.
|
||||
A server record names one LiveKit deployment, the API key pair the instance authenticates to it with, and its own set of eligibility fields. A server is available for placement only when its region also admits the placement.
|
||||
|
||||
The `region_id` and `server_id` pair addresses one server, and a server belongs to exactly one region. The same server identifier can exist in two regions. Moving a server between regions means deleting it and recreating it. Both identifiers are operator-chosen strings of 1 to 64 characters, and no operation renames either one.
|
||||
|
||||
@@ -123,7 +123,7 @@ A server can also have a soft connection limit, described under [soft connection
|
||||
|
||||
<sup>2</sup> The two coordinates are set and cleared together, and a server with only one of them cannot be stored
|
||||
|
||||
<sup>3</sup> Fluxer skips an inactive server when it resolves a new placement. Server-side moderation of a session already on it keeps working
|
||||
<sup>3</sup> Fluxer skips an inactive server when it resolves a new placement. Fluxer can still mute, deafen, change the permissions of, or disconnect a participant already on it
|
||||
|
||||
<sup>4</sup> Duplicate entries collapse and the returned order is not the submitted order
|
||||
|
||||
@@ -256,7 +256,7 @@ Stores a region and returns it. Requires `voice:region:create`.
|
||||
| allowed_guild_ids? | array[snowflake] | The guilds admitted without checking the other guild gates (max 1000, default empty) |
|
||||
| allowed_user_ids? | array[snowflake] | The accounts allowed to use the region at all (max 1000, default empty) |
|
||||
|
||||
<sup>1</sup> The identifier is not checked for collision. Reusing the identifier of an existing region overwrites that record in full, resets its creation time to now, and replaces every one of its collections
|
||||
<sup>1</sup> The identifier is not checked for collision. Reusing the identifier of an existing region overwrites that record in full, resets its creation time to now, and replaces its `required_guild_features`, `allowed_guild_ids`, and `allowed_user_ids` arrays
|
||||
|
||||
<sup>2</sup> Each item is 1 to 64 characters. A value that is not a real guild feature is stored as supplied and then matches no guild
|
||||
|
||||
@@ -370,7 +370,7 @@ There is no confirmation step or recovery operation. Save the server configurati
|
||||
|
||||
### Side effects
|
||||
|
||||
Operations that need a deleted server fail once the change takes effect. Existing sessions are not disconnected by the deletion itself.
|
||||
After each API node reloads its topology, Fluxer can no longer mute, deafen, change the permissions of, or remove a participant on a deleted server. Existing sessions are not disconnected by the deletion itself.
|
||||
|
||||
One [Admin audit entry](/admin-api/#admin-audit-entry-object) with the action `delete_voice_region` and the target type `voice_region` records the region identifier and its name in metadata. No `delete_voice_server` entry is written for the servers deleted with the region.
|
||||
|
||||
@@ -596,7 +596,7 @@ Deletes a voice server. Requires `voice:server:delete`.
|
||||
|
||||
### Side effects
|
||||
|
||||
Each node reloads its topology after the removal, and server-side moderation of a session still on that server then fails. Sessions already placed on the server are not disconnected by the deletion itself. The stored credentials are removed with the record and are not recoverable.
|
||||
Each node reloads its topology after the removal, and Fluxer can then no longer mute, deafen, change the permissions of, or remove a participant still on that server. Sessions already placed on the server are not disconnected by the deletion itself. The stored credentials are removed with the record and are not recoverable.
|
||||
|
||||
One [Admin audit entry](/admin-api/#admin-audit-entry-object) with the action `delete_voice_server` and the target type `voice_server` records the region identifier, the server identifier, and the endpoint the server held in metadata.
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ A [sudo mode](#sudo-mode) proof supplements the token through the separate `X-Fl
|
||||
|
||||
## Bot tokens
|
||||
|
||||
The Gateway accepts a bot token in [Identify](/gateway/commands/#identify). The HTTP operations [`GET /v1/gateway/bot`](/http-api/gateway/#get-gateway-information) and [`GET /v1/applications/@me`](/http-api/applications/#get-bot-application) accept case-insensitive scheme prefixes. `GET /v1/applications/@me` specifically requires `Bot` and returns 401 `INVALID_TOKEN` for anything else. The [Gateway authentication](#gateway-authentication) section covers the other route.
|
||||
The Gateway accepts a bot token in [Identify](/gateway/commands/#identify). The HTTP operations [`GET /v1/gateway/bot`](/http-api/gateway/#get-gateway-information) and [`GET /v1/applications/@me`](/http-api/applications/#get-bot-application) accept case-insensitive scheme prefixes. `GET /v1/applications/@me` specifically requires `Bot` and returns 401 `INVALID_TOKEN` for anything else. The [Gateway authentication](#gateway-authentication) section covers `GET /v1/gateway/bot`.
|
||||
|
||||
A bot cannot use an operation restricted to ordinary user accounts, and such an operation returns 403 `ACCESS_DENIED`. An operation in [Authentication](/http-api/authentication/) that resolves an account from its request body or token, such as login, password recovery, email verification, email revert, and IP authorisation, returns 403 `BOT_USER_AUTH_ENDPOINT_ACCESS_DENIED` when that account is a bot.
|
||||
|
||||
@@ -63,7 +63,7 @@ Every OAuth2 access token belongs to an account. Supported grants are authorisat
|
||||
|
||||
Only operations that explicitly support OAuth2 accept access tokens. A user operation without that support returns 403 `ACCESS_DENIED` for a valid access token.
|
||||
|
||||
Scopes apply only to OAuth2 access tokens, not to user session credentials accepted by the same operation. A bearer-only operation rejects a session token, bot token, or Admin API key with 401 `UNAUTHORIZED`.
|
||||
An operation that accepts both a user session token and an OAuth2 access token checks the scope only on the access token. A bearer-only operation rejects a session token, bot token, or Admin API key with 401 `UNAUTHORIZED`.
|
||||
|
||||
A missing scope returns 403 `MISSING_OAUTH_SCOPE`. Each operation requires its named scope exactly. The [OAuth2 HTTP API](/http-api/oauth2/) defines the supported [scopes](/http-api/oauth2/#oauth2-scopes), grants, refresh, revocation, and introspection.
|
||||
|
||||
@@ -98,7 +98,7 @@ A credential can affect even an unauthenticated operation. It selects account-ba
|
||||
|
||||
A protected operation returns 401 `UNAUTHORIZED` for a missing, malformed, unknown, expired, or revoked credential. Bot tokens on Admin operations and non-bearer credentials on bearer-only operations also return 401.
|
||||
|
||||
A valid identity denied by the operation returns 403 `ACCESS_DENIED`, subject to the credential-specific exceptions above.
|
||||
A valid identity denied by the operation returns 403 `ACCESS_DENIED`. An [Authentication](/http-api/authentication/) operation that resolves a bot account returns 403 `BOT_USER_AUTH_ENDPOINT_ACCESS_DENIED`, as [Bot tokens](#bot-tokens) describes.
|
||||
|
||||
Scope and Admin permission failures use the specific codes above. A 401 has no `WWW-Authenticate` header, so clients must inspect `code`.
|
||||
|
||||
@@ -123,7 +123,7 @@ Enforcement applies at those operations only, and does not gate password change
|
||||
|
||||
## Account state gates
|
||||
|
||||
The ordinary login requirement rejects an account that has effective suspicious activity flags with 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`. A requirement disappears from the response as soon as it is met.
|
||||
An ordinary authenticated operation rejects an account that has an unmet suspicious activity requirement with 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`. Each set flag in the response is one requirement the account has not met. A flag no longer appears in the response once the account meets that requirement.
|
||||
|
||||
### Account suspicious activity body
|
||||
|
||||
@@ -131,7 +131,7 @@ The ordinary login requirement rejects an account that has effective suspicious
|
||||
| --- | --- | --- |
|
||||
| data | object | An object whose `suspicious_activity_flags` member is the integer [suspicious activity flag](/admin-api/users/#suspicious-activity-flags) bitfield still outstanding |
|
||||
|
||||
A route that explicitly admits restricted accounts still accepts the credential. These stay reachable while a requirement is outstanding:
|
||||
A route that explicitly admits an account with an unmet suspicious activity requirement still accepts its credential. These stay reachable while a requirement is outstanding:
|
||||
|
||||
- [Get current user](/http-api/users/current-user/#get-current-user) and [Modify current user](/http-api/users/current-user/#modify-current-user).
|
||||
- [Get current user settings](/http-api/users/settings/#get-current-user-settings).
|
||||
@@ -156,7 +156,7 @@ Sudo mode is a short-lived proof that the account holder recently re-verified a
|
||||
|
||||
A sudo proof lasts five minutes. Present it in the `X-Fluxer-Sudo-Mode-JWT` request header. An invalid, expired, or account-mismatched token produces the same response as a missing one.
|
||||
|
||||
Fluxer issues a token only for an account holding a multi-factor authenticator, so a password-only account re-verifies for each operation that requires sudo mode. [Create WebAuthn registration options](/http-api/users/mfa/#create-webauthn-registration-options) and [Disable current account](/http-api/users/current-user/#disable-current-account) issue no token and return no header even for a multi-factor account. A bot account satisfies sudo mode immediately. So does an account that has neither a password nor a multi-factor authenticator.
|
||||
Fluxer issues a token only for an account holding a multi-factor authenticator, so a password-only account re-verifies for each operation that requires sudo mode. [Create WebAuthn registration options](/http-api/users/mfa/#create-webauthn-registration-options) and [Disable current account](/http-api/users/current-user/#disable-current-account) issue no sudo token and return no `X-Fluxer-Sudo-Mode-JWT` response header, even for a multi-factor account. A bot account satisfies sudo mode immediately. So does an account that has neither a password nor a multi-factor authenticator.
|
||||
|
||||
:::note[A sudo proof covers every account session]
|
||||
Revoking the session that obtained a proof leaves that proof valid until it expires.
|
||||
|
||||
@@ -13,7 +13,7 @@ Except for [Heartbeat](#heartbeat), [Identify](#identify), and [Resume](#resume)
|
||||
| Opcode | Command | Result |
|
||||
| --- | --- | --- |
|
||||
| 1 | Heartbeat | Opcode `11` Heartbeat ACK |
|
||||
| 2 | Identify | [Ready](/gateway/events/#ready), a close frame, or silence when the payload is held or discarded |
|
||||
| 2 | Identify | [Ready](/gateway/events/#ready), a close frame, or no response when Fluxer discards a rate-limited Identify or holds it to retry the session start |
|
||||
| 3 | Presence Update | No direct response |
|
||||
| 4 | Voice State Update | [Voice State Update](/gateway/events/#voice-state-update) and [Voice Server Update](/gateway/events/#voice-server-update) when state changes |
|
||||
| 6 | Resume | Replayed Dispatches followed by [Resumed](/gateway/events/#resumed), Invalid Session, or a close frame |
|
||||
@@ -77,7 +77,7 @@ Opcode `2` authenticates and creates a new session.
|
||||
|
||||
<sup>2</sup> Names are upper-cased and deduplicated. See [Event filtering](/gateway/event-filtering/) for the exact suppression rule
|
||||
|
||||
<sup>3</sup> The guild delivers active traffic without a [Lazy Request](#lazy-request) and sends no initial [Guild Sync](/gateway/events/#guild-sync). A value that is not a canonical decimal Snowflake string is ignored without failing Identify
|
||||
<sup>3</sup> The session is active in that guild without a [Lazy Request](#lazy-request), as [Event filtering](/gateway/event-filtering/#active-and-passive-guilds) describes, and receives no initial [Guild Sync](/gateway/events/#guild-sync). A value that is not a canonical decimal Snowflake string is ignored without failing Identify
|
||||
|
||||
<sup>4</sup> `shard_count` is an integer from 1 through 16,384, and `shard_id` is an integer that is at least 0 and below `shard_count`
|
||||
|
||||
@@ -203,7 +203,7 @@ Opcode `6` restores a retained session.
|
||||
|
||||
All fields are required. A missing field, a non-string `token` or `session_id`, or a `seq` that is not an integer closes with `4002` and reason `Invalid resume payload`.
|
||||
|
||||
An unknown or expired session produces Opcode `9` with `d: false` and leaves the socket unauthenticated. A `seq` below the [replay floor](/gateway/limits-and-rate-limits/#replay-and-backpressure), the highest sequence already dropped from the buffer, produces the same frame. A token that does not own the session closes with `4004` and reason `Invalid token`. A `seq` above the session's current sequence, or below the sequence it has already acknowledged, closes with `4007` and reason `Invalid sequence`. A negative `seq` closes with `4000` and reason `Session unavailable`, and so does a session that cannot be reached. None of those closes destroys a separately retained session.
|
||||
An unknown or expired session produces Opcode `9` with `d: false` and leaves the socket unauthenticated. A `seq` below the [replay floor](/gateway/limits-and-rate-limits/#replay-and-backpressure), the highest sequence already dropped from the buffer, produces the same frame. A token that does not own the session closes with `4004` and reason `Invalid token`. A `seq` above the session's current sequence, or below the sequence it has already acknowledged, closes with `4007` and reason `Invalid sequence`. A negative `seq` closes with `4000` and reason `Session unavailable`, and so does a session that cannot be reached. None of those closes ends the retained session that `session_id` names.
|
||||
|
||||
A successful Resume replays every retained Dispatch strictly above `seq` in order and finishes with [Resumed](/gateway/events/#resumed). It also replaces the session's socket, and the displaced socket receives Opcode `7` followed by a close.
|
||||
|
||||
@@ -294,7 +294,7 @@ Every field is optional. A non-null `guild_id` or `channel_id` is a canonical de
|
||||
|
||||
`latitude` and `longitude` accept a number or a string here, and Fluxer coerces both to a string.
|
||||
|
||||
The command has no `session_id` field. The current Gateway session is the membership identity.
|
||||
The command has no `session_id` field. Fluxer identifies the voice membership by the Gateway session that sends the command.
|
||||
|
||||
Joining or replacing a grant produces [Voice Server Update](/gateway/events/#voice-server-update) with the token and endpoint for the media connection, and [Voice State Update](/gateway/events/#voice-state-update) for every session that can see the channel.
|
||||
|
||||
@@ -401,7 +401,7 @@ Opcode `14` sets the per-guild subscriptions that decide member list, typing, an
|
||||
| member_list_channels?<sup>2</sup> | map[snowflake, array[array[integer]]] | The member list windows to subscribe to, keyed by channel ID |
|
||||
| members? | array[snowflake] | The explicit member IDs to subscribe to, at most 1,000 |
|
||||
|
||||
<sup>1</sup> Both are Booleans when present. Any other value drops the rest of the command silently, without a close and without a result
|
||||
<sup>1</sup> Both are Booleans when present. Any other value stops Fluxer from applying the remaining options for that guild and every guild it has not yet processed, without a close and without a result
|
||||
|
||||
<sup>2</sup> Subscriptions may be combined over a 100 ms window. Ranges sent during that window are merged for the same channel, and an empty range list clears its pending ranges
|
||||
|
||||
@@ -421,7 +421,7 @@ Subscribing a channel to at least one range drops the session's other member lis
|
||||
|
||||
## Request Guild Counts
|
||||
|
||||
Opcode `15` requests current count records.
|
||||
Opcode `15` requests the current member and online counts for guilds.
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -444,7 +444,7 @@ Results arrive in one [Guild Counts Update](/gateway/events/#guild-counts-update
|
||||
|
||||
## Request Channel Member Counts
|
||||
|
||||
Opcode `16` requests count records for channels in one guild.
|
||||
Opcode `16` requests the member and online counts for channels in one guild.
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -16,10 +16,10 @@ Fluxer evaluates a guild-scoped Dispatch against these gates in order.
|
||||
| --- | --- | --- |
|
||||
| 1 | Guild availability | Whether the guild dispatches anything except [Guild Update](/gateway/events/#guild-update) |
|
||||
| 2 | Permission and visibility | Which sessions may see the event at all |
|
||||
| 3 | Guild subscription state | Whether a passive session in a large guild falls inside the fixed subset that still receives it |
|
||||
| 3 | Guild subscription state | Whether a passive session in a guild with more than 250 members still receives it, as [Active and passive guilds](#active-and-passive-guilds) lists |
|
||||
| 4 | Session-level filters | Whether the shard filter, and then the `ignored_events` list, drops it inside the session after the guild has already chosen the recipients |
|
||||
|
||||
A guild with the `UNAVAILABLE_FOR_EVERYONE` or `UNAVAILABLE_FOR_EVERYONE_BUT_STAFF` feature fails gate 1. `UNAVAILABLE_FOR_EVERYONE_BUT_STAFF` decides whether the guild hands a session its full state or an `unavailable` stub when the session connects. Gate 1 has no staff exemption, so a staff session also receives nothing but Guild Update while the feature is set.
|
||||
A guild with the `UNAVAILABLE_FOR_EVERYONE` or `UNAVAILABLE_FOR_EVERYONE_BUT_STAFF` feature fails gate 1. `UNAVAILABLE_FOR_EVERYONE_BUT_STAFF` decides what a session receives when it connects. A staff session receives the guild's full state, and every other session receives an `unavailable` stub. Gate 1 has no staff exemption, so a staff session also receives nothing but Guild Update while the feature is set.
|
||||
|
||||
An account-scoped Dispatch skips gates 1 through 3 and is subject only to gate 4. Direct message traffic, relationship changes, and account record changes arrive that way.
|
||||
|
||||
@@ -41,7 +41,7 @@ Every one of those sets excludes a session that has not yet received the guild's
|
||||
|
||||
Channel visibility is `VIEW_CHANNEL` on the channel, plus extensions. A category is visible when at least one of its children is visible. A user with a live voice connection in a channel keeps virtual access to it whenever the channel would otherwise stop being visible. That covers a role or overwrite change removing `VIEW_CHANNEL`, and a move into a channel the user cannot view. Virtual access is keyed by user, so it applies to every session of that user. It is dropped when the user's voice connection to the channel ends.
|
||||
|
||||
Message access is `READ_MESSAGE_HISTORY` on the channel. Without that permission a session still receives events for messages newer than the guild's message history cutoff. A guild that sets no cutoff offers no such fallback, so a session without `READ_MESSAGE_HISTORY` receives none of the message-access filtered events there.
|
||||
Message access is `READ_MESSAGE_HISTORY` on the channel. Without that permission a session still receives events for messages newer than the guild's [message history cutoff](/http-api/guilds/#guild-object). A guild that sets no cutoff offers no such fallback, so a session without `READ_MESSAGE_HISTORY` receives none of the message-access filtered events there.
|
||||
|
||||
[Channel Update Bulk](/gateway/events/#channel-update-bulk) contains only channels the recipient can view. If none are visible, no event is sent.
|
||||
|
||||
@@ -88,7 +88,7 @@ Every 30 seconds a passive session receives [Passive Updates](/gateway/events/#p
|
||||
|
||||
[Typing Start](/gateway/events/#typing-start) never follows the rule above. When the session set `typing` for the guild through [Lazy Request](/gateway/commands/#lazy-request), that value alone decides delivery. Without an override the event follows the active state, so a passive session in a large guild does not receive it.
|
||||
|
||||
The override applies to every session, including a bot session. A bot suppresses Typing Start in one guild through that override alone.
|
||||
The override applies to every session, including a bot session. The only way for a bot to stop Typing Start in one guild and keep it in other guilds is to set `typing` to false for that guild.
|
||||
|
||||
### Member lists
|
||||
|
||||
@@ -129,7 +129,7 @@ A session that identified with a `shard` pair whose `shard_id` is not 0 drops ev
|
||||
|
||||
Account-level traffic, direct message traffic, relationship changes, and calls therefore never reach a session on a shard other than 0.
|
||||
|
||||
Sessions on shard 0, and sessions that identified without a `shard` pair, filter nothing at this gate. Fluxer still applies guild ownership at Identify, as [Sharding](/gateway/overview/#sharding) describes, so a shard 0 session is only ever connected to the guilds its shard owns.
|
||||
Sessions on shard 0, and sessions that identified without a `shard` pair, filter nothing at this gate. Fluxer still filters guilds by shard at Identify, as [Sharding](/gateway/overview/#sharding) describes, so a shard 0 session is only ever connected to the guilds its shard owns.
|
||||
|
||||
## What a bot should send
|
||||
|
||||
|
||||
@@ -24,11 +24,11 @@ A Dispatch reports a change or command result through the [Gateway](/gateway/ove
|
||||
}
|
||||
```
|
||||
|
||||
[Ready](#ready) establishes sequence 1. Live Dispatches advance it by one. Replayed Dispatches keep their original sequence and can have gaps. [Resumed](#resumed) has the session's current sequence without advancing it and establishes the new live baseline. The sequence is local to one Gateway session and orders nothing across shards or HTTP operations.
|
||||
[Ready](#ready) establishes sequence 1. Live Dispatches advance it by one. Replayed Dispatches keep their original sequence and can have gaps. [Resumed](#resumed) has the session's current sequence without advancing it. The next live Dispatch has that sequence plus one. The sequence is local to one Gateway session and orders nothing across shards or HTTP operations.
|
||||
|
||||
## Dispatch delivery
|
||||
|
||||
[Event filtering](/gateway/event-filtering/) defines delivery by guild availability, channel visibility, permissions, and session settings. Account-scoped Dispatches use only the session-level filters.
|
||||
[Event filtering](/gateway/event-filtering/) defines delivery by guild availability, channel visibility, permissions, and session settings. Account-scoped Dispatches go through only the session-level filters, which are the shard filter and the `ignored_events` list.
|
||||
|
||||
Most guild-scoped Dispatches have a `guild_id` string. [Guild Create](#guild-create) and [Guild Sync](#guild-sync) identify the guild as `id`, and so does every [Guild Delete](#guild-delete) other than the one the guild itself dispatches when the guild is deleted. [Guild Counts Update](#guild-counts-update) and [Channel Member Counts Update](#channel-member-counts-update) have no top-level `guild_id`, and each entry in their `counts` array has its own.
|
||||
|
||||
@@ -62,15 +62,15 @@ A Dispatch is buffered for [Resume](/gateway/commands/#resume) replay unless it
|
||||
| [Favorite Meme Update](#favorite-meme-update) | One of the current user's memes changes | Current user |
|
||||
| [Favorite Meme Delete](#favorite-meme-delete) | The current user deletes a meme | Current user |
|
||||
| [Guild Create](#guild-create) | A guild becomes available to the session | Guild connection |
|
||||
| [Guild Sync](#guild-sync) | A subscribed session receives a replacement guild snapshot | Guild connection |
|
||||
| [Guild Sync](#guild-sync) | A subscribed session receives the guild's full current state again | Guild connection |
|
||||
| [Guild Update](#guild-update) | A guild's configuration changes | Guild connection |
|
||||
| [Guild Delete](#guild-delete) | A guild leaves the session's visibility or becomes unavailable | Guild connection |
|
||||
| [Guild Role Create](#guild-role-create) | A role is created in a guild | Guild connection |
|
||||
| [Guild Role Update](#guild-role-update) | Exactly one role record changes | Guild connection |
|
||||
| [Guild Role Update Bulk](#guild-role-update-bulk) | One operation changes several role records together | Guild connection |
|
||||
| [Guild Role Delete](#guild-role-delete) | A role is deleted from a guild | Guild connection |
|
||||
| [Guild Emojis Update](#guild-emojis-update) | A guild's emoji collection is replaced | Guild connection |
|
||||
| [Guild Stickers Update](#guild-stickers-update) | A guild's sticker collection is replaced | Guild connection |
|
||||
| [Guild Emojis Update](#guild-emojis-update) | A guild's emojis change | Guild connection |
|
||||
| [Guild Stickers Update](#guild-stickers-update) | A guild's stickers change | Guild connection |
|
||||
| [Channel Create](#channel-create) | A channel becomes visible to the session | Channel visibility |
|
||||
| [Channel Update](#channel-update) | A visible channel changes | Channel visibility |
|
||||
| [Channel Update Bulk](#channel-update-bulk) | One operation changes several channels together | Channel visibility |
|
||||
@@ -90,7 +90,7 @@ A Dispatch is buffered for [Resume](/gateway/commands/#resume) replay unless it
|
||||
| [Guild Ban Remove](#guild-ban-remove) | A guild ban is removed | Guild connection |
|
||||
| [Presence Update](#presence-update) | One visible presence changes | Presence subscription |
|
||||
| [Presence Update Bulk](#presence-update-bulk) | A recovering guild delivers its visible presences together | Guild connection |
|
||||
| [Passive Updates](#passive-updates) | Passive channel watermarks and voice states advance for one session | Passive session |
|
||||
| [Passive Updates](#passive-updates) | Channel `last_message_id` values or voice states changed for one passive session | Passive session |
|
||||
| [Message Create](#message-create) | A visible message is created | Channel visibility |
|
||||
| [Message Update](#message-update) | A visible message changes and is republished in full | Message access |
|
||||
| [Message Delete](#message-delete) | One visible message is deleted | Message access |
|
||||
@@ -170,8 +170,8 @@ The same structure appears in Ready, [Guild Create](#guild-create), and [Guild S
|
||||
| properties | [guild](/http-api/guilds/#guild-object) object | Guild record without its roles, channels, emojis, stickers, or members |
|
||||
| roles | array[[guild role](/http-api/permissions/#guild-role-object) object] | Every role in the guild |
|
||||
| channels | array[[channel](/http-api/channels/#channel-object) object] | Channels the session can view |
|
||||
| emojis | array[[guild emoji](/http-api/guild-emojis/#guild-emoji-object) object] | Guild emojis |
|
||||
| stickers | array[[guild sticker](/http-api/guild-stickers/#guild-sticker-object) object] | Guild stickers |
|
||||
| emojis | array[[guild emoji](/http-api/guild-emojis/#guild-emoji-object) object] | Every emoji in the guild |
|
||||
| stickers | array[[guild sticker](/http-api/guild-stickers/#guild-sticker-object) object] | Every sticker in the guild |
|
||||
| members<sup>1</sup> | array[[guild member](/http-api/guild-members/#guild-member-object) object] | The members the session needs immediately |
|
||||
| member_count | integer | Total member count |
|
||||
| online_count<sup>2</sup> | integer | Online member count |
|
||||
@@ -189,7 +189,7 @@ The same structure appears in Ready, [Guild Create](#guild-create), and [Guild S
|
||||
|
||||
An unavailable guild is reduced to `id` and `unavailable: true`, plus `unavailable_hidden: true` when the guild is hidden. It has none of the other fields.
|
||||
|
||||
Inside [Ready](#ready), and inside the [Guild Create](#guild-create) burst a bot receives immediately after Ready, each member of this object has its `user` replaced by `{"id": "..."}`. On a user session the removed accounts appear in the Ready payload's `users` array. A bot's `users` array is empty, so a bot pulls those accounts with [Request Guild Members](/gateway/commands/#request-guild-members). A [Guild Create](#guild-create) sent later in the session, and every [Guild Sync](#guild-sync), have the members with `user` intact.
|
||||
Inside [Ready](#ready), and inside the [Guild Create](#guild-create) burst a bot receives immediately after Ready, each entry in `members` has its `user` replaced by `{"id": "..."}`. On a user session the removed accounts appear in the Ready payload's `users` array. A bot's `users` array is empty, so a bot pulls those accounts with [Request Guild Members](/gateway/commands/#request-guild-members). A [Guild Create](#guild-create) sent later in the session, and every [Guild Sync](#guild-sync), have the members with `user` intact.
|
||||
|
||||
#### Session presence object
|
||||
|
||||
@@ -200,7 +200,7 @@ Inside [Ready](#ready), and inside the [Guild Create](#guild-create) burst a bot
|
||||
| afk | boolean | Whether the session is away |
|
||||
| mobile | boolean | Whether the session is mobile |
|
||||
|
||||
The first entry always has `session_id: "all"` and the account's flattened status.
|
||||
The first entry always has `session_id: "all"` and the account's combined status, which is the first of `dnd`, `online`, `idle`, and `invisible` that any of its sessions has, or `offline` when none has one.
|
||||
|
||||
#### WebAuthn credential object
|
||||
|
||||
@@ -229,11 +229,11 @@ Sent after a successful [Resume](/gateway/commands/#resume) has replayed every r
|
||||
| --- | --- | --- |
|
||||
| _timings_gw? | object | Gateway-side timing breakdown, present only for a staff account |
|
||||
|
||||
The payload is otherwise empty. Resumed has the session's current sequence in `s` without advancing it, and that sequence becomes the new live baseline.
|
||||
The payload is otherwise empty. Resumed has the session's current sequence in `s` without advancing it, and the next live Dispatch has that sequence plus one.
|
||||
|
||||
### <span id="sessions-replace"></span>SESSIONS_REPLACE
|
||||
|
||||
The account's set of live sessions changed. The payload, a bare JSON array of [session presence objects](#session-presence-object), replaces the client's copy in full. [Ready](#ready) sends the initial set as `sessions`.
|
||||
The account's set of live sessions changed. The payload is a bare JSON array of [session presence objects](#session-presence-object) and is always the complete set. A client that stores the sessions replaces them with this array. [Ready](#ready) sends the initial set as `sessions`.
|
||||
|
||||
### <span id="auth-session-change"></span>AUTH_SESSION_CHANGE
|
||||
|
||||
@@ -288,7 +288,7 @@ The current user wrote or cleared a private note.
|
||||
|
||||
### <span id="user-pinned-dms-update"></span>USER_PINNED_DMS_UPDATE
|
||||
|
||||
The current user's pinned private channel set changed. The payload is a bare JSON array of channel ID strings in pinned order and replaces the client's copy in full. [Ready](#ready) sends the initial set as `pinned_dms`.
|
||||
The current user's pinned private channel set changed. The payload is a bare JSON array of channel ID strings in pinned order and is always the complete set. A client that stores the pinned channels replaces them with this array. [Ready](#ready) sends the initial set as `pinned_dms`.
|
||||
|
||||
### <span id="user-connections-update"></span>USER_CONNECTIONS_UPDATE
|
||||
|
||||
@@ -298,11 +298,11 @@ The current user's external connection set changed.
|
||||
| --- | --- | --- |
|
||||
| connections | array[connection object] | Every connection the account holds |
|
||||
|
||||
The array replaces the client's copy in full.
|
||||
`connections` is always the complete set. A client that stores the connections replaces them with this array.
|
||||
|
||||
### <span id="webauthn-credentials-update"></span>WEBAUTHN_CREDENTIALS_UPDATE
|
||||
|
||||
The current user's WebAuthn credential set changed. The payload is a bare JSON array of [WebAuthn credential objects](#webauthn-credential-object) and replaces the client's copy in full. [Ready](#ready) sends the initial set as `webauthn_credentials`.
|
||||
The current user's WebAuthn credential set changed. The payload is a bare JSON array of [WebAuthn credential objects](#webauthn-credential-object) and is always the complete set. A client that stores the credentials replaces them with this array. [Ready](#ready) sends the initial set as `webauthn_credentials`.
|
||||
|
||||
### <span id="relationship-add"></span>RELATIONSHIP_ADD
|
||||
|
||||
@@ -378,15 +378,15 @@ The current user deleted a meme.
|
||||
|
||||
A guild became available to the session. The payload is a [guild ready object](#guild-ready-object).
|
||||
|
||||
Every collection in the event replaces the client's copy for that guild.
|
||||
`roles`, `channels`, `emojis`, `stickers`, and `voice_states` are always complete. A client that stores any of them for the guild replaces its stored list with the new array. `members` is a partial list. A client adds or updates those members and keeps every other member it already stores.
|
||||
|
||||
A user session receives Guild Create when a guild becomes available after Ready, for example after joining one or after an unavailable guild recovers. A bot session receives one for every guild in the burst that follows Ready.
|
||||
|
||||
### <span id="guild-sync"></span>GUILD_SYNC
|
||||
|
||||
A session that asked for a sync through [Lazy Request](/gateway/commands/#lazy-request) receives a replacement snapshot of the guild. The payload is a [guild ready object](#guild-ready-object) and has the same replacement semantics as [Guild Create](#guild-create).
|
||||
A session that asked for a sync through [Lazy Request](/gateway/commands/#lazy-request) receives the guild's full current state again. The payload is a [guild ready object](#guild-ready-object), and a client handles it the same way as [Guild Create](#guild-create).
|
||||
|
||||
Fluxer sends a sync when the subscription switches the guild between active and passive, and when `sync: true` names a guild the session has not already synced. A second `sync: true` for an already-synced guild sends nothing.
|
||||
Fluxer sends a sync when a Lazy Request switches the guild between [active and passive](/gateway/event-filtering/#active-and-passive-guilds), and when `sync: true` names a guild the session has not already synced. A second `sync: true` for an already-synced guild sends nothing.
|
||||
|
||||
### <span id="guild-update"></span>GUILD_UPDATE
|
||||
|
||||
@@ -407,7 +407,7 @@ A guild left the session's visibility, or became unavailable.
|
||||
|
||||
<sup>1</sup> Present only when the guild itself is deleted. The payload has `id` alone when the account leaves a guild or is removed from one, and `id` with `unavailable` in the unavailable form
|
||||
|
||||
Without `unavailable`, the account is no longer a member and the client discards the guild. With `unavailable: true`, the guild is retained in a placeholder state and a later [Guild Create](#guild-create) restores it.
|
||||
Without `unavailable`, the account is no longer a member, and a client deletes everything it stores for that guild. With `unavailable: true`, the guild is temporarily unreachable. A client keeps the guild as an unavailable entry until a later [Guild Create](#guild-create) sends its full state again.
|
||||
|
||||
### <span id="guild-role-create"></span>GUILD_ROLE_CREATE
|
||||
|
||||
@@ -447,25 +447,25 @@ A role was deleted from a guild.
|
||||
|
||||
### <span id="guild-emojis-update"></span>GUILD_EMOJIS_UPDATE
|
||||
|
||||
A guild's emoji collection changed.
|
||||
A guild's emojis changed.
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| guild_id | snowflake | Guild the emojis belong to |
|
||||
| emojis | array[[guild emoji](/http-api/guild-emojis/#guild-emoji-object) object] | The complete emoji collection |
|
||||
| emojis | array[[guild emoji](/http-api/guild-emojis/#guild-emoji-object) object] | Every emoji in the guild |
|
||||
|
||||
The array replaces the client's copy for that guild. Fluxer does not send per-emoji create, update, or delete events.
|
||||
A client that stores the guild's emojis replaces them with this array. Fluxer does not send per-emoji create, update, or delete events.
|
||||
|
||||
### <span id="guild-stickers-update"></span>GUILD_STICKERS_UPDATE
|
||||
|
||||
A guild's sticker collection changed.
|
||||
A guild's stickers changed.
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| guild_id | snowflake | Guild the stickers belong to |
|
||||
| stickers | array[[guild sticker](/http-api/guild-stickers/#guild-sticker-object) object] | The complete sticker collection |
|
||||
| stickers | array[[guild sticker](/http-api/guild-stickers/#guild-sticker-object) object] | Every sticker in the guild |
|
||||
|
||||
The array replaces the client's copy for that guild. Fluxer does not send per-sticker create, update, or delete events.
|
||||
A client that stores the guild's stickers replaces them with this array. Fluxer does not send per-sticker create, update, or delete events.
|
||||
|
||||
### <span id="channel-create"></span>CHANNEL_CREATE
|
||||
|
||||
@@ -549,7 +549,7 @@ A user became a member of a guild the session is connected to. The payload is th
|
||||
|
||||
A member's guild state or public user representation changed. The payload is the complete [guild member object](/http-api/guild-members/#guild-member-object) with `guild_id` added.
|
||||
|
||||
In a large guild, a passive session receives this event only when the subject is its own user.
|
||||
In a guild with more than 250 members, a [passive](/gateway/event-filtering/#active-and-passive-guilds) session receives this event only when the subject is its own user.
|
||||
|
||||
### <span id="guild-member-remove"></span>GUILD_MEMBER_REMOVE
|
||||
|
||||
@@ -562,7 +562,7 @@ A user stopped being a member of a guild the session is connected to.
|
||||
|
||||
<sup>1</sup> The object has `id` alone. No other account field is sent, so a client MUST resolve the account from state it already holds
|
||||
|
||||
In a large guild, a passive session receives this event only when the subject is its own user.
|
||||
In a guild with more than 250 members, a [passive](/gateway/event-filtering/#active-and-passive-guilds) session receives this event only when the subject is its own user.
|
||||
|
||||
### <span id="guild-members-chunk"></span>GUILD_MEMBERS_CHUNK
|
||||
|
||||
@@ -758,7 +758,7 @@ One visible message was deleted.
|
||||
| guild_id? | snowflake | Guild the channel belongs to |
|
||||
| member?<sup>2</sup> | [guild member](/http-api/guild-members/#guild-member-object) object | The author's guild member object, present in a guild channel |
|
||||
|
||||
<sup>1</sup> Both fields are omitted when the deletion came from moderation tools, and `author_id` is also omitted for a message with no author
|
||||
<sup>1</sup> Both fields are omitted when an instance administrator deleted the message through the Admin API, when Fluxer deleted it after a CSAM report, or when Fluxer deleted it because content moderation blocked a link preview in it, and `author_id` is also omitted for a message with no author
|
||||
|
||||
<sup>2</sup> The `user` field is removed from it, and the whole field is absent when `author_id` is absent or the author is no longer a member
|
||||
|
||||
@@ -824,7 +824,7 @@ With the `DEBOUNCE_MESSAGE_REACTIONS` [session flag](/gateway/commands/#session-
|
||||
|
||||
<sup>1</sup> Every addition in `reactions` belongs to this message. A window covering several messages produces a separate Dispatch for each
|
||||
|
||||
When the window closes holding exactly one addition, the session sends [Message Reaction Add](#message-reaction-add) instead. A session without the flag receives one Message Reaction Add per addition.
|
||||
When the window closes holding exactly one addition, Fluxer sends [Message Reaction Add](#message-reaction-add) to the session. A session without the flag receives one Message Reaction Add per addition.
|
||||
|
||||
#### Reaction addition object
|
||||
|
||||
@@ -884,7 +884,7 @@ A visible user began typing in a channel.
|
||||
| guild_id? | snowflake | Guild the channel belongs to |
|
||||
| member? | [guild member](/http-api/guild-members/#guild-member-object) object | The typing user's guild member object, present in a guild channel |
|
||||
|
||||
The `typing` override set through [Lazy Request](/gateway/commands/#lazy-request) decides delivery in a guild. With no override, a session receives the event when it is active in the guild or when the guild has 250 members or fewer, so a passive session in a small guild still receives it. A guild that sets the `TYPING_EVENTS` bit in its [disabled operations](/http-api/guilds/#disabled-guild-operations) produces the event for nobody.
|
||||
The `typing` override set through [Lazy Request](/gateway/commands/#lazy-request) decides delivery in a guild. With no override, a session receives the event when it is [active](/gateway/event-filtering/#active-and-passive-guilds) in the guild or when the guild has 250 members or fewer, so a passive session in a small guild still receives it. A guild that sets the `TYPING_EVENTS` bit in its [disabled operations](/http-api/guilds/#disabled-guild-operations) produces the event for nobody.
|
||||
|
||||
### <span id="channel-pins-update"></span>CHANNEL_PINS_UPDATE
|
||||
|
||||
@@ -911,7 +911,7 @@ The current user acknowledged a channel's pins. Every session of the account rec
|
||||
|
||||
A participant's voice state changed. The payload is a [voice state object](#voice-state-object).
|
||||
|
||||
Recipients are the sessions that can view the voice channel, passive sessions included. A passive session in a large guild also receives the changed voice states through [Passive Updates](#passive-updates).
|
||||
Recipients are the sessions that can view the voice channel, passive sessions included. A passive session in a guild with more than 250 members also receives the changed voice states through [Passive Updates](#passive-updates).
|
||||
|
||||
A `channel_id` of null means the participant left.
|
||||
|
||||
@@ -937,7 +937,7 @@ A `channel_id` of null means the participant left.
|
||||
| e2ee_capable | boolean | Whether the participant's client supports end-to-end encrypted voice |
|
||||
| version | integer | Monotonic version of this participant's voice state |
|
||||
|
||||
<sup>1</sup> Publisher-asserted. In a guild voice channel Fluxer sets it to false when the participant lacks `STREAM`
|
||||
<sup>1</sup> The participant's client reports this value. In a guild voice channel Fluxer sets it to false when the participant lacks `STREAM`
|
||||
|
||||
The broadcast form has no `region_id`, `server_id`, `latitude`, or `longitude`.
|
||||
|
||||
@@ -1018,7 +1018,7 @@ A call ended, or became unavailable.
|
||||
|
||||
<sup>1</sup> Absent when the call ended
|
||||
|
||||
With `unavailable: true`, the client MUST retain a placeholder for the call. If it becomes available again, a fresh [Call Create](#call-create) includes `recipients` and `created_at`. Recovery is not guaranteed.
|
||||
With `unavailable: true`, the client MUST keep the call as an unavailable entry. If it becomes available again, a fresh [Call Create](#call-create) includes `recipients` and `created_at`. Recovery is not guaranteed.
|
||||
|
||||
## Count response events
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Every main Gateway connection has limits on what it sends, how long its session
|
||||
|
||||
[Framing](/gateway/overview/#framing) owns the protocol version, the payload bound, and the compression contract. One inbound WebSocket message is limited to 4,096 bytes on the wire and to a further 4,096 bytes after decompression, and either bound closes with `4002` and reason `Payload too large`.
|
||||
|
||||
A compressed message that decompresses past 10 MiB closes with `4002` and reason `Decompression failed`, before the 4,096-byte bound is reached.
|
||||
A compressed message that decompresses past 10 MiB closes with `4002` and reason `Decompression failed`. That message never closes with `Payload too large`.
|
||||
|
||||
## Session lifecycle
|
||||
|
||||
@@ -25,7 +25,7 @@ One user credential holds at most 100 live sessions. A further Identify closes w
|
||||
Shard counts run from 1 through 16,384. One bot shard covers at most 2,500 guilds. A malformed shard pair closes with `4010` for every credential. A bot assignment above the guild ceiling closes with `4011` and reason `Sharding required`. A user session is never refused for its guild count.
|
||||
|
||||
:::note[Session creation can be delayed]
|
||||
During maintenance, a rollout or temporary capacity limits, an Identify can remain pending without a response. Continue heartbeating while waiting for Ready.
|
||||
An Identify can remain pending with no response while the node drains, while the node is at capacity, while session starts are paused, or while the account is outside the session rollout percentage. Continue heartbeating while waiting for Ready.
|
||||
:::
|
||||
|
||||
## Session start limit
|
||||
@@ -88,7 +88,7 @@ Lazy Request accepts at most 10 member list ranges per channel, each with `end`
|
||||
|
||||
Request Guild Counts accepts at most 100 guild IDs after deduplication. Request Channel Member Counts accepts at most 25 channel IDs after deduplication. Both nonces run from 1 through 64 bytes, and a nonce outside that bound is omitted from the result.
|
||||
|
||||
Identify accepts at most 256 `ignored_events` entries. A longer array closes with `4002` and reason `Invalid identify payload`. Every other command payload bound coerces or drops. [Client commands](/gateway/commands/) states the exact coercion or drop rule for each field.
|
||||
Identify accepts at most 256 `ignored_events` entries. A longer array closes with `4002` and reason `Invalid identify payload`. Fluxer coerces or drops a value outside any other command payload bound. [Client commands](/gateway/commands/) states the exact coercion or drop rule for each field.
|
||||
|
||||
## Voice admission
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ An opcode is the number that names a [Gateway payload](/gateway/overview/#gatewa
|
||||
|
||||
<sup>4</sup> Resume is accepted whether or not a session is already attached to the connection
|
||||
|
||||
<sup>5</sup> Opcode 7 precedes the close when the Gateway node is draining, when the session is fenced for a cluster handoff, and when a Resume from a new socket displaces this one
|
||||
<sup>5</sup> Opcode 7 precedes the close when the Gateway node is draining, when the node transfers the session to another Gateway node, and when a Resume from a new socket displaces this one
|
||||
|
||||
<sup>6</sup> After the frame, a socket whose session ended is unauthenticated. After a failed Resume, a socket that already held a session still holds it
|
||||
|
||||
@@ -80,7 +80,7 @@ A client SHOULD log an unknown opcode and ignore the frame, and MUST NOT close o
|
||||
|
||||
| Code | Name | Meaning |
|
||||
| --- | --- | --- |
|
||||
| 4000 | Unknown error | The Gateway drained the connection, or a session operation could not be completed |
|
||||
| 4000 | Unknown error | Drain, an unclassified session creation error, or a Resume whose retained session could not be reached |
|
||||
| 4001 | Unknown opcode | The opcode is undefined, is a server opcode, or the payload has no `d` |
|
||||
| 4002 | Decode error | The payload size, compression stream, encoding, or command fields are invalid |
|
||||
| 4003 | Not authenticated | An authenticated command arrived before Identify or Resume attached a session |
|
||||
@@ -95,7 +95,7 @@ A client SHOULD log an unknown opcode and ignore the frame, and MUST NOT close o
|
||||
|
||||
<sup>1</sup> `shard_count` is an integer from 1 to 16384, and `shard_id` is a non-negative integer below `shard_count`
|
||||
|
||||
<sup>2</sup> The count is taken after the shard filter, so a bot clears it by identifying with a `shard_count` large enough to divide its guilds
|
||||
<sup>2</sup> The count is taken after the shard filter, so a bot clears it by identifying with a `shard_count` large enough that no shard owns more than 2,500 guilds
|
||||
|
||||
Code 4006 is unassigned, and no code above 4012 is defined. [Event filtering](/gateway/event-filtering/) describes how a client bounds the events its session receives.
|
||||
|
||||
@@ -120,7 +120,7 @@ Code 4006 is unassigned, and no code above 4012 is defined. [Event filtering](/g
|
||||
|
||||
<sup>2</sup> A Resume that fails token verification leaves the named session in place for the rest of its retention window, so a later Resume with the owning token still recovers it. An Identify that fails token verification leaves nothing to recover
|
||||
|
||||
`Resumable` describes only whether an already established session can still be recovered with [Resume](/gateway/commands/#resume). The 60,000 ms retention window and the bounded replay buffer described in [Limits and rate limits](/gateway/limits-and-rate-limits/#replay-and-backpressure) apply unchanged.
|
||||
`Resumable` describes only whether an already established session can still be recovered with [Resume](/gateway/commands/#resume). The 60,000 ms retention window and the bounded replay buffer described in [Limits and rate limits](/gateway/limits-and-rate-limits/#replay-and-backpressure) still limit whether a Resume succeeds and what it replays.
|
||||
|
||||
:::caution[Reconnecting unchanged reproduces `4004`, `4010`, and `4012`]
|
||||
A client changes the token, the shard pair, or the version before it reconnects.
|
||||
@@ -176,7 +176,7 @@ The Gateway sends an exact reason string with every application close.
|
||||
|
||||
<sup>3</sup> The Gateway holds the Identify and retries it silently after a classified transient failure, so the connection stays open. That covers a paused rollout, a draining node, an ineligible account, an Identify rate limit, a saturated start budget, and a failed session RPC
|
||||
|
||||
<sup>4</sup> The Gateway node is draining, the session is being fenced for a cluster handoff, or a Resume from a new socket displaced this one
|
||||
<sup>4</sup> The Gateway node is draining, the node is transferring the session to another Gateway node, or a Resume from a new socket displaced this one
|
||||
|
||||
Reason strings are stable wire values. A client branches on the code and MAY record the reason for diagnosis.
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ Append the connection parameters to the discovered URL.
|
||||
|
||||
<sup>3</sup> `compress=zstd-stream` selects compression only when `stream` is also `1` or `true`. Without the flag the connection is uncompressed
|
||||
|
||||
Unknown parameters are ignored. An unrecognised `compress` or `stream` value selects no compression and the connection stays open. A client MUST read the negotiated representation from the frame type it receives.
|
||||
Unknown parameters are ignored. An unrecognised `compress` or `stream` value selects no compression and the connection stays open. A client MUST read whether the connection is compressed from the frame type it receives. On a `zstd-stream` connection every server frame is a binary frame.
|
||||
|
||||
The reference client connects with `?v=1&encoding=json&compress=zstd-stream&stream=1`.
|
||||
|
||||
@@ -86,7 +86,7 @@ A decoded payload that is not a JSON object closes with `4002` and reason `Decod
|
||||
|
||||
A Dispatch is a server-to-client event payload. Every live Dispatch advances the session sequence by one. A session starts at sequence 0, so the [Ready](/gateway/events/#ready) sequence is 1.
|
||||
|
||||
A replayed Dispatch keeps its original sequence, and a replayed run can have gaps, because several event families are delivered live and never retained. [Resumed](/gateway/events/#resumed) has the current sequence and does not advance it, which sets the new live baseline. The sequence is local to one Gateway session and has no meaning across sessions or shards.
|
||||
A replayed Dispatch keeps its original sequence, and a replayed run can have gaps, because Guild Sync, Guild Member List Update, and Guild Members Chunk are delivered live and never retained. [Resumed](/gateway/events/#resumed) has the current sequence and does not advance it. The next live Dispatch after Resumed has that sequence plus one. The sequence is local to one Gateway session and has no meaning across sessions or shards.
|
||||
|
||||
## Framing
|
||||
|
||||
@@ -94,7 +94,7 @@ One inbound WebSocket message is limited to 4,096 bytes on the wire, and a compr
|
||||
|
||||
JSON payloads are UTF-8 objects. An uncompressed client payload is sent in a text frame, and a payload the client compressed with the negotiated zstd stream is sent in a binary frame.
|
||||
|
||||
The Gateway does not inspect the inbound frame type. It reads the bytes from the negotiated compression alone. A client MUST send every payload in the negotiated representation. On a connection with no negotiated compression the payload is uncompressed, and on a `zstd-stream` connection every client payload goes through the same compression stream in order.
|
||||
The Gateway does not inspect the inbound frame type. It decodes every inbound message with the compression the connection negotiated, whatever the frame type. A client MUST send every payload in the negotiated representation. On a connection with no negotiated compression the payload is uncompressed, and on a `zstd-stream` connection every client payload goes through the same compression stream in order.
|
||||
|
||||
### JSON integer representation
|
||||
|
||||
@@ -133,7 +133,7 @@ A connection moves through these states: Opening, Unauthenticated, Starting, Rep
|
||||
| Identify. Valid Identify payload and Identify capacity available | Begin session creation | Starting |
|
||||
| Identify. Gateway draining, node at capacity, session starts paused, or the account outside the session rollout | Hold the payload and retry it in the background | Unauthenticated |
|
||||
| Identify. The source IP Identify budget is exhausted | Discard the payload without a reply | Unauthenticated |
|
||||
| Resume. Valid Resume payload | Resolve the retained session | Starting |
|
||||
| Resume. Valid Resume payload | Look up the retained session named by `session_id` | Starting |
|
||||
| Authenticated command. Any command other than Heartbeat, Identify, or Resume | Close with `4003` and reason `Not authenticated` | Closed |
|
||||
|
||||
### Starting
|
||||
@@ -141,8 +141,8 @@ A connection moves through these states: Opening, Unauthenticated, Starting, Rep
|
||||
| Event and condition | Action | Next state |
|
||||
| --- | --- | --- |
|
||||
| Session creation succeeds. Identify was accepted | Send Ready | Ready |
|
||||
| Session creation fails permanently. Invalid token, invalid shard, sharding required, or too many sessions | Close with the mapped code and reason | Closed |
|
||||
| Session creation fails without a mapped code | Close with `4000` and reason `Failed to start session` | Closed |
|
||||
| Session creation fails permanently. Invalid token, invalid shard, sharding required, or too many sessions | Close with the code and reason that [Hello and session creation](#hello-and-session-creation) lists for that failure | Closed |
|
||||
| Session creation fails with an error the Gateway does not classify | Close with `4000` and reason `Failed to start session` | Closed |
|
||||
| Session creation fails temporarily. Draining, at capacity, RPC failure, timeout, or the account outside the session rollout | Hold the Identify and retry it in the background | Unauthenticated |
|
||||
| Resume succeeds. The retained session accepted the sequence | Replay retained Dispatches | Replaying |
|
||||
| Session cannot be resumed. Resume named an unknown or expired session, or a `seq` below the replay floor | Send Invalid Session with `d: false` | Unauthenticated |
|
||||
@@ -178,7 +178,7 @@ A connection moves through these states: Opening, Unauthenticated, Starting, Rep
|
||||
| --- | --- | --- |
|
||||
| Heartbeat. No session is attached, or the payload is `null`, or the attached session accepts the sequence | Send Heartbeat ACK | Same state |
|
||||
| Heartbeat deadline. The connection is awaiting an acknowledgement and more than 45,000 ms have passed since the last one | Close with `4009` and reason `Heartbeat timeout` | Closed |
|
||||
| Invalid frame or payload. Size, decompression, or decoding validation fails | Close with the applicable close code | Closed |
|
||||
| Invalid frame or payload. Size, decompression, or decoding validation fails | Close with `4002` and the matching reason from [Gateway payload](#gateway-payload) or [Framing](#framing) | Closed |
|
||||
| Transport ends. A session exists | Retain the session for 60,000 ms | Closed |
|
||||
|
||||
An opcode outside the registry, and a server opcode sent by a client, close with `4001` once a session is attached and with `4003` while the connection is unauthenticated.
|
||||
@@ -221,9 +221,9 @@ Opcode 1 is accepted before and after authentication. Before a session exists it
|
||||
}
|
||||
```
|
||||
|
||||
The server answers with Opcode 11 Heartbeat ACK, which has no `d`. Once a session is attached, a `d` value that is neither `null` nor an integer closes with `4007` and reason `Invalid sequence`. A session that does not answer within 5,000 ms closes with the same code and reason.
|
||||
The server answers with Opcode 11 Heartbeat ACK, which has no `d`. Once a session is attached, a `d` value that is neither `null` nor an integer closes with `4007` and reason `Invalid sequence`. When the Gateway cannot confirm the sequence with the session within 5,000 ms, the connection closes with the same code and reason.
|
||||
|
||||
The server can request an immediate heartbeat with Opcode 1 and `d: null`. Answer it with your own Opcode 1. Continue sending heartbeats at the advertised interval. A connection that misses the heartbeat deadline closes with `4009` and reason `Heartbeat timeout`.
|
||||
The server requests an immediate heartbeat with Opcode 1 and `d: null` once 90 per cent of the interval has passed since the last acknowledgement. Answer it with your own Opcode 1. Continue sending heartbeats at the advertised interval. A connection that misses the heartbeat deadline closes with `4009` and reason `Heartbeat timeout`.
|
||||
|
||||
A heartbeat with a sequence permanently trims every retained Dispatch at or below that sequence from the replay buffer and records it as the acknowledged sequence. A client MUST send the sequence it has processed, because a later Resume from a lower sequence closes with `4007`.
|
||||
|
||||
@@ -257,7 +257,7 @@ Unlike Identify, Resume is accepted in every open state. A socket that already h
|
||||
When the resumed session was attached to a different socket, that socket receives Opcode 7 Reconnect and then closes with `4000`.
|
||||
|
||||
:::caution[Retention covers reconnection recovery only]
|
||||
[Limits and rate limits](/gateway/limits-and-rate-limits/#replay-and-backpressure) states the exact bounds, and several high-volume Dispatch events are never retained.
|
||||
[Limits and rate limits](/gateway/limits-and-rate-limits/#replay-and-backpressure) states the exact bounds, and Guild Sync, Guild Member List Update, and Guild Members Chunk are never retained.
|
||||
:::
|
||||
|
||||
## Reconnect
|
||||
@@ -270,7 +270,7 @@ Opcode 7 Reconnect asks the client to open a new WebSocket. The Gateway sends it
|
||||
}
|
||||
```
|
||||
|
||||
The current socket then closes with `4000` and reason `Session drain requested; reconnect to continue`. The session can be resumed while it remains inside its retention bounds.
|
||||
The current socket then closes with `4000` and reason `Session drain requested; reconnect to continue`. A Resume sent within 60,000 ms of the close can recover the session, subject to the sequence bounds in [Resuming a session](#resuming-a-session).
|
||||
|
||||
## Invalid session
|
||||
|
||||
@@ -298,7 +298,7 @@ For a user session, the filtered set is also the [Ready](/gateway/events/#ready)
|
||||
Fluxer checks only a bot session against the guild ceiling. A bot whose shard owns more than 2,500 guilds closes with `4011` and reason `Sharding required`. A bot that supplies no pair is checked against its whole guild list. A user session is bounded by the 100-session-per-user limit alone, whatever its guild count.
|
||||
|
||||
:::note[Shard 0 also receives account-level traffic]
|
||||
The per-Dispatch shard filter in [Dispatch delivery](/gateway/events/#dispatch-delivery) runs only when `shard_id` is not 0. No other shard receives a copy, so handle those events on shard 0.
|
||||
The per-Dispatch shard filter in [Dispatch delivery](/gateway/events/#dispatch-delivery) runs only when `shard_id` is not 0. A session with a non-zero `shard_id` drops every Dispatch that names no guild, apart from Rate Limited, Guild Counts Update, and Channel Member Counts Update. Account-level Dispatches name no guild, so handle them on shard 0.
|
||||
:::
|
||||
|
||||
Fluxer has no large bot tier, no shard-count alignment requirement, and no Identify concurrency buckets. `GET /v1/gateway/bot` returns a fixed recommendation.
|
||||
@@ -307,4 +307,4 @@ Fluxer has no large bot tier, no shard-count alignment requirement, and no Ident
|
||||
|
||||
Dispatch ordering applies within one Gateway session. It creates no total order across shards, HTTP responses, or Media Proxy operations.
|
||||
|
||||
[Guild Create](/gateway/events/#guild-create) and [Guild Sync](/gateway/events/#guild-sync) are replacement boundaries for the guild they name. Everything else is a delta against the state those boundaries established.
|
||||
[Guild Create](/gateway/events/#guild-create) and [Guild Sync](/gateway/events/#guild-sync) send the complete roles, channels, emojis, stickers, and voice states for the guild they name, and a client replaces its stored lists with them, as Guild Create describes. Every other Dispatch for that guild changes part of that stored state.
|
||||
|
||||
@@ -10,9 +10,9 @@ An application is an OAuth2 client owned by one user account, and every bot acco
|
||||
|
||||
## Access rules
|
||||
|
||||
Every route except [Get public application](#get-public-application) requires a credential. A user-only route rejects a bot token and an OAuth2 bearer with 403 `ACCESS_DENIED`. Fluxer accepts an account with an outstanding required action everywhere here.
|
||||
Every route except [Get public application](#get-public-application) requires a credential. A user-only route rejects a bot token and an OAuth2 bearer with 403 `ACCESS_DENIED`. Every route on this page accepts an account that has an outstanding [required action](/http-api/users/#required-actions).
|
||||
|
||||
The sudo-gated routes read `X-Fluxer-Sudo-Mode-JWT`. A valid proof for the authenticated account satisfies [sudo mode](/http-api/users/mfa/#sudo-mode) on its own, and Fluxer echoes it back in the response header. Fluxer issues no token to an account with no authenticator, so that account proves sudo mode with `password` in the body.
|
||||
The sudo-gated routes read `X-Fluxer-Sudo-Mode-JWT`. A valid sudo token in that header, issued to the authenticated account, satisfies [sudo mode](/http-api/users/mfa/#sudo-mode) with no body proof. Fluxer returns the same token in the `X-Fluxer-Sudo-Mode-JWT` response header. Fluxer issues no token to an account with no authenticator, so that account proves sudo mode with `password` in the body.
|
||||
|
||||
:::caution[Credentials are shown once]
|
||||
A client secret is returned only by [Create application](#create-application) and [Reset client secret](#reset-client-secret), and a [bot token](/authentication/#token-formats) only by [Create application](#create-application) and [Reset bot token](#reset-bot-token).
|
||||
@@ -20,7 +20,7 @@ A client secret is returned only by [Create application](#create-application) an
|
||||
|
||||
## Rate limits
|
||||
|
||||
A bucket name ending in `::client_id` still counts one allowance for the authenticated user across every application that user owns. [List owned applications](#list-owned-applications), [Get current application](#get-current-application), [Get application](#get-application), [Get public application](#get-public-application), and [List OAuth2 authorisations](/http-api/oauth2/#list-oauth2-authorisations) all draw on one `oauth_dev:clients:list` allowance. [Get bot application](#get-bot-application) draws on the same bucket, keyed by the bot account the token names. That allowance is separate from the owner's.
|
||||
A bucket whose name ends in `::client_id` gives the authenticated user one allowance. Requests for every application that user owns count against that one allowance. [List owned applications](#list-owned-applications), [Get current application](#get-current-application), [Get application](#get-application), [Get public application](#get-public-application), and [List OAuth2 authorisations](/http-api/oauth2/#list-oauth2-authorisations) all draw on one `oauth_dev:clients:list` allowance. [Get bot application](#get-bot-application) draws on the same bucket, keyed by the bot account the token names. That allowance is separate from the owner's.
|
||||
|
||||
## Application object
|
||||
|
||||
@@ -42,7 +42,7 @@ The application record as its owner sees it.
|
||||
|
||||
<sup>2</sup> Present only in the response to [Create application](#create-application) and [Reset client secret](#reset-client-secret), which are the only operations that issue one
|
||||
|
||||
<sup>3</sup> Present only when the operation resolved the bot account, which excludes [Reset client secret](#reset-client-secret), an application that owns no bot account, and an application whose bot account record can no longer be read
|
||||
<sup>3</sup> Present in every response except [Reset client secret](#reset-client-secret), and absent for an application that owns no bot account or whose bot account record can no longer be read
|
||||
|
||||
### Example
|
||||
|
||||
@@ -109,7 +109,7 @@ The application as any caller sees it, including a caller that presents no crede
|
||||
| bot<sup>4</sup> | ?[application bot](#application-bot-object) object | The bot account the application owns |
|
||||
| current_user?<sup>5</sup> | ?[partial user](/http-api/users/#partial-user-object) object | The requesting account |
|
||||
|
||||
<sup>1</sup> Null when the application has no bot account or that account has no avatar, and the stored hash is reported unchanged
|
||||
<sup>1</sup> Null when the application has no bot account or that account has no avatar. Otherwise it is the bot account's stored avatar hash, and an animated hash keeps its `a_` prefix
|
||||
|
||||
<sup>2</sup> Contains `bot` when the application owns a bot account, and is otherwise empty
|
||||
|
||||
@@ -121,7 +121,7 @@ The application as any caller sees it, including a caller that presents no crede
|
||||
|
||||
## Current bot application object
|
||||
|
||||
The application as the bot token that application issued sees it.
|
||||
The application that issued the requesting bot token, as returned to that token.
|
||||
|
||||
### Structure
|
||||
|
||||
@@ -140,7 +140,7 @@ The application as the bot token that application issued sees it.
|
||||
|
||||
<sup>1</sup> Read from the bot account, so it changes with [Update bot profile](#update-bot-profile), and it is null when the application has no bot account
|
||||
|
||||
<sup>2</sup> No application signing key is available
|
||||
<sup>2</sup> Always 64 `0` characters, because no application signing key is available
|
||||
|
||||
<sup>3</sup> The request fails with 401 `INVALID_TOKEN` when the owning account no longer exists
|
||||
|
||||
@@ -150,7 +150,7 @@ The application as the bot token that application issued sees it.
|
||||
|
||||
## Bot profile object
|
||||
|
||||
The bot account's profile fields after an update. [Update bot profile](#update-bot-profile) is the only operation that returns this shape, and it has no bot token.
|
||||
The bot account's profile fields after an update. [Update bot profile](#update-bot-profile) is the only operation that returns this shape. The object has no `token` field.
|
||||
|
||||
### Structure
|
||||
|
||||
@@ -285,7 +285,7 @@ This representation exposes the full registered redirect URI list and the bot pr
|
||||
|
||||
Creates an application together with its bot account. Returns the [application](#application-object) object with the initial client secret and bot token.
|
||||
|
||||
An unclaimed account cannot create an application. An account is unclaimed while it holds no password credential, is not a bot, and does not have the single sign-on trait.
|
||||
An unclaimed account cannot create an application. An account is unclaimed while it holds no password credential, is not a bot, and has not been linked to a single sign-on identity.
|
||||
|
||||
### Request headers
|
||||
|
||||
@@ -294,7 +294,7 @@ An unclaimed account cannot create an application. An account is unclaimed while
|
||||
| X-Captcha-Token?<sup>1</sup> | string | The CAPTCHA proof for the request |
|
||||
| X-Captcha-Type?<sup>2</sup> | string | The CAPTCHA provider to verify against, either `hcaptcha` or `turnstile` |
|
||||
|
||||
<sup>1</sup> A missing proof returns 400 `CAPTCHA_REQUIRED` and a rejected proof returns 400 `INVALID_CAPTCHA`. Fluxer skips verification when CAPTCHA is disabled for the instance, when the account has the CAPTCHA exemption flag, or when the caller's contact has the CAPTCHA exemption capability, as described by [CAPTCHA handling](/topics/captcha/)
|
||||
<sup>1</sup> A missing proof returns 400 `CAPTCHA_REQUIRED` and a rejected proof returns 400 `INVALID_CAPTCHA`. Fluxer skips verification when CAPTCHA is disabled for the instance, when the account has the CAPTCHA exemption flag, or when the instance's account policy grants the caller's email address the CAPTCHA exemption capability, as described by [CAPTCHA handling](/topics/captcha/)
|
||||
|
||||
<sup>2</sup> Fluxer verifies against the instance's configured provider when the header is absent
|
||||
|
||||
@@ -405,7 +405,7 @@ Removing a redirect URI takes effect immediately for new authorisation requests.
|
||||
|
||||
### Side effects
|
||||
|
||||
The submitted fields replace the matching application configuration. Neither credential is rotated, and no Gateway Dispatch is emitted.
|
||||
Each submitted field replaces the stored value of that field. Neither credential is rotated, and no Gateway Dispatch is emitted.
|
||||
|
||||
### Rate limit
|
||||
|
||||
@@ -444,7 +444,7 @@ Updates the bot account an application owns and returns the resulting [bot profi
|
||||
|
||||
<sup>5</sup> Fluxer reads only the [bot flags](#bot-flags) from the supplied bitfield and sets or clears each to match. It ignores every other bit
|
||||
|
||||
Fluxer checks the decoded bytes of `avatar` and `banner` against the instance's avatar byte ceiling, which applies to both fields and defaults to 10 MiB. Each image also passes the format allowlist and the animation rules of the asset policy for the field it sets. Pixel dimensions are never checked.
|
||||
Fluxer checks the decoded bytes of `avatar` and `banner` against the instance's avatar byte ceiling, which applies to both fields and defaults to 10 MiB. An image in a format the field does not accept, an animated image on a field that accepts no animation, and any animated AVIF return the field code `INVALID_IMAGE_FORMAT`. Pixel dimensions are never checked.
|
||||
|
||||
### Response
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ The public single sign-on state. The same object is embedded by the [instance di
|
||||
| display_name | ?string | The configured provider display name, or null when none is set |
|
||||
| redirect_uri | string | The default OAuth2 redirect URI used for the provider callback |
|
||||
|
||||
<sup>1</sup> The value is true only when the operator has enabled SSO and the resolved provider configuration is complete, so a partially configured provider reports false
|
||||
<sup>1</sup> The value is true only when the operator has enabled SSO and the provider has an authorisation URL, a token URL, a client ID, and a JWKS URL or user info URL. Each URL is configured or discovered from the issuer. A provider that lacks any of them reports false
|
||||
|
||||
<sup>2</sup> The value is true only when `enabled` is also true, and every local authentication operation then returns 403 `SSO_REQUIRED`
|
||||
|
||||
@@ -85,7 +85,7 @@ The parameters for sending the user to the identity provider, bound to one new S
|
||||
| state<sup>1</sup> | string | The one-use CSRF state |
|
||||
| redirect_uri | string | The callback URI bound to this state |
|
||||
|
||||
<sup>1</sup> The state is consumed by the first [complete SSO](#complete-sso) attempt that resolves it
|
||||
<sup>1</sup> The first [complete SSO](#complete-sso) request that presents the unexpired state consumes it, whether or not that request then succeeds
|
||||
|
||||
A client MUST return the state unchanged and MUST NOT interpret its contents.
|
||||
|
||||
@@ -264,8 +264,8 @@ The code that identifies one pending desktop handoff.
|
||||
|
||||
<sup>1</sup> The code is 12 characters drawn from the alphabet `ABCDEFGHJKMNPQRSTUVWXYZ23456789`, rendered as two groups of six separated by a hyphen
|
||||
|
||||
:::caution[The code alone cancels a handoff]
|
||||
Anyone who learns the code reads the pending device metadata and cancels the handoff. The token needs `poll_secret` as well. A client MUST show the code only to the person performing the handoff.
|
||||
:::caution[The code alone reads the device metadata]
|
||||
Anyone who learns the code reads the pending device metadata. Reading the token and cancelling the handoff both require `poll_secret`. A client MUST show the code only to the person performing the handoff.
|
||||
:::
|
||||
|
||||
## Handoff information object
|
||||
@@ -407,9 +407,9 @@ Approval mode registration instead returns 403 `REGISTRATION_PENDING_APPROVAL` a
|
||||
|
||||
<RouteHeader method="POST" path="/v1/auth/register" unauthenticated />
|
||||
|
||||
Creates an ordinary account. Returns an [authentication token response](#authentication-token-response) when the instance admits the account immediately and a [registration pending approval response](#registration-pending-approval-response-object) when it does not. Emits a [Guild Member Add](/gateway/events/#guild-member-add) Gateway event when an invite or instance community admission takes effect.
|
||||
Creates an ordinary account. Returns an [authentication token response](#authentication-token-response) when the instance admits the account immediately and a [registration pending approval response](#registration-pending-approval-response-object) when it does not. Emits a [Guild Member Add](/gateway/events/#guild-member-add) Gateway event for each guild the new account joins through an invite or through the instance's single community guild.
|
||||
|
||||
Registration verifies [CAPTCHA](/topics/captcha/) when CAPTCHA is enabled. It permits 3 attempts per hour for each client IP address and 15 per hour for each client subnet, which is the IPv4 /24 or IPv6 /48 network. A supplied email address permits 3 attempts per 15 minutes of its own. Those allowances are separate from the route bucket, and only a deployment that relaxes registration rate limits disables them.
|
||||
Registration verifies [CAPTCHA](/topics/captcha/) when CAPTCHA is enabled. It permits 3 attempts per hour for each client IP address and 15 per hour for each client subnet, which is the IPv4 /24 or IPv6 /48 network. A supplied email address permits 3 attempts per 15 minutes of its own. Those allowances are separate from the route bucket, and only a deployment with `dev.relax_registration_rate_limits` set to true disables them.
|
||||
|
||||
### Request headers
|
||||
|
||||
@@ -478,7 +478,7 @@ There is no retry key. A repeated request with the same values creates a second
|
||||
|
||||
The operation applies the instance's registration, email-domain, breached-password, and regional policies before creating the account, and its risk policy can set suspicious activity flags on the created account. It records the accepted terms and privacy policy, authorises the registering client IP address, and sends an email verification message when the instance sends email. An instance that sends no email marks the address verified at creation instead.
|
||||
|
||||
Registration can accept a supplied or instance-configured invite, and the account can join the instance community. Each join emits [Guild Member Add](/gateway/events/#guild-member-add) to the affected guild's sessions. Registration policy can suppress invite admission.
|
||||
Registration accepts the `invite_code` from the body, or the instance's configured auto-join invite when the body has none. On an instance with single-community mode enabled, the account also joins the community guild. Each join emits [Guild Member Add](/gateway/events/#guild-member-add) to the affected guild's sessions. Registration policy can suppress invite admission.
|
||||
|
||||
Approval mode registration creates no guild membership and no authentication session, and the account cannot sign in until an administrator approves it. Every other successful registration creates one session and returns its token.
|
||||
|
||||
@@ -533,7 +533,7 @@ Account policy can return 403 `REGISTRATION_PENDING_APPROVAL`, 403 `REGISTRATION
|
||||
On an account the user had disabled, a correct password clears the disabled state. It also cancels a self-scheduled deletion if erasure has not started. Both happen before any second factor is requested, without a separate confirmation step. Once erasure starts, login returns `ACCOUNT_SUSPENDED_PERMANENTLY`.
|
||||
:::
|
||||
|
||||
The login clears an expired temporary suspension the same way, but returns the 403 of a live temporary or permanent administrator suspension without clearing it.
|
||||
A correct password also clears an expired temporary suspension before any second factor is requested. A live temporary or permanent administrator suspension stays in place, and the login returns its 403.
|
||||
|
||||
### Response
|
||||
|
||||
@@ -860,7 +860,7 @@ Password recovery verifies CAPTCHA when CAPTCHA is enabled. Fluxer consumes both
|
||||
<sup>1</sup> An address whose domain has no usable DNS records returns the field code `INVALID_EMAIL_ADDRESS`, while an address that passes DNS validation but belongs to no account returns the ordinary success response
|
||||
|
||||
:::note[Account existence is not disclosed]
|
||||
An address that resolves to no account produces the same 204 response as an address that resolves to an ordinary account. Only DNS validation failure, the route bucket, the extra allowances, and an address belonging to a bot account can produce a different outcome.
|
||||
An address that resolves to no account produces the same 204 response as an address that resolves to an ordinary account. Only a DNS validation failure, the route bucket, the client IP address and email address allowances, and an address belonging to a bot account can produce a different outcome.
|
||||
:::
|
||||
|
||||
### Response
|
||||
@@ -943,7 +943,7 @@ An unknown or already consumed token, and a token bound to a deleted account, re
|
||||
Passwords are checked against a breached-password corpus, as they are during [registration](#register-an-account) and [email reversion](#revert-an-email-change).
|
||||
|
||||
:::caution[Resetting a password revokes every existing session]
|
||||
A successful reset terminates every authentication session on the account, including the one any other device is holding. It then issues one fresh session. An account with a second factor instead receives an MFA ticket, and the session follows the factor.
|
||||
A successful reset terminates every authentication session on the account, including the one any other device is holding. It then issues one fresh session. An account with a second factor receives an MFA ticket, and completing MFA with that ticket creates the session.
|
||||
:::
|
||||
|
||||
### Response
|
||||
@@ -971,7 +971,7 @@ An account with no second factor then receives one new session and its token. An
|
||||
|
||||
<RouteHeader method="POST" path="/v1/auth/email-revert" unauthenticated />
|
||||
|
||||
Consumes the token delivered to the previous email address, restores that address, replaces the password, and rebuilds the account's credential state. Returns an [authentication token response](#authentication-token-response). Emits a [User Update](/gateway/events/#user-update) Gateway event.
|
||||
Consumes the token delivered to the previous email address, restores that address, replaces the password, terminates every authentication session, and clears every second factor. The requesting IP address becomes the only authorised IP address. Returns an [authentication token response](#authentication-token-response). Emits a [User Update](/gateway/events/#user-update) Gateway event.
|
||||
|
||||
The token is valid for 24 hours after the address change that issued it.
|
||||
|
||||
@@ -1005,7 +1005,7 @@ Fluxer checks the replacement password against the same breached-password corpus
|
||||
|
||||
### Side effects
|
||||
|
||||
The previous email address becomes verified and the replacement password takes effect. All other sessions, second factors and authorised IP addresses are removed. Only the requesting IP address remains authorised.
|
||||
The previous email address becomes verified and the replacement password becomes the account password. All other sessions, second factors and authorised IP addresses are removed. Only the requesting IP address remains authorised.
|
||||
|
||||
The account receives [User Update](/gateway/events/#user-update). Existing Gateway sessions end as described under [shared behaviour](#shared-behaviour), and the response returns one new authentication session.
|
||||
|
||||
@@ -1115,7 +1115,7 @@ An unknown, expired, already consumed, or account-mismatched token returns the f
|
||||
|
||||
| Status | Body | Condition |
|
||||
| --- | --- | --- |
|
||||
| 204 | empty | The IP address was authorised and the login result was published |
|
||||
| 204 | empty | The IP address was authorised and the new session token is readable through poll IP authorisation |
|
||||
| 400 | [error response](/http-api/#error-response) | The body or authorisation token is invalid |
|
||||
| 403 | [error response](/http-api/#error-response) | SSO enforcement rejects the operation or the token resolves to a bot account |
|
||||
| 404 | [error response](/http-api/#error-response) | The token resolves to an account that no longer exists |
|
||||
@@ -1147,7 +1147,7 @@ Sends the authorisation message for an outstanding IP authorisation ticket again
|
||||
|
||||
<sup>1</sup> The resend reuses the authorisation token already bound to the ticket, so a message delivered by an earlier send remains valid
|
||||
|
||||
An unknown or expired ticket returns the field code `INVALID_OR_EXPIRED_AUTHORIZATION_TICKET`. A resend before the delay elapses returns 429 `IP_AUTHORIZATION_RESEND_COOLDOWN` with a `Retry-After` header and a top-level `resend_available_in` in seconds. A second resend returns 400 `IP_AUTHORIZATION_RESEND_LIMIT_EXCEEDED`.
|
||||
An unknown or expired ticket returns the field code `INVALID_OR_EXPIRED_AUTHORIZATION_TICKET`. A resend less than 30 seconds after the ticket was issued returns 429 `IP_AUTHORIZATION_RESEND_COOLDOWN` with a `Retry-After` header and a top-level `resend_available_in` in seconds. A second resend returns 400 `IP_AUTHORIZATION_RESEND_LIMIT_EXCEEDED`.
|
||||
|
||||
:::caution[A lost response still spends the resend]
|
||||
There is no retry key. A client that loses the response cannot tell whether the message was delivered, and a retry returns 400 `IP_AUTHORIZATION_RESEND_LIMIT_EXCEEDED` once the first request marked the resend used.
|
||||
@@ -1414,7 +1414,7 @@ Reports the state of a handoff and delivers the new session token once, to a cal
|
||||
| --- | --- | --- |
|
||||
| code | string | The handoff code, using the same normalisation contract as [get desktop handoff information](#get-desktop-handoff-information) |
|
||||
|
||||
### Request body
|
||||
### JSON body
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -1457,18 +1457,24 @@ Discards a handoff and everything stored against its code. Authentication is not
|
||||
| --- | --- | --- |
|
||||
| code | string | The handoff code, using the same normalisation contract as [get desktop handoff information](#get-desktop-handoff-information) |
|
||||
|
||||
An unknown or already expired code is an idempotent success.
|
||||
### JSON body
|
||||
|
||||
:::caution[Anyone holding the code can cancel]
|
||||
The route checks no credential and the code is its only input, so a party that learns the code can cancel a handoff the initiating device is still waiting on. The initiating device observes this as the `expired` status.
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| poll_secret | string | The secret returned by [initiate desktop handoff](#initiate-desktop-handoff) |
|
||||
|
||||
An unknown or already expired code has no stored secret, so it returns 400 `INVALID_HANDOFF_CODE`.
|
||||
|
||||
:::caution[Cancelling requires the poll secret]
|
||||
The route requires the `poll_secret` that [initiate desktop handoff](#initiate-desktop-handoff) returned, sent in the JSON body. A wrong secret returns 400 `INVALID_HANDOFF_CODE`, so a party that knows only the code cannot cancel the handoff. After a cancellation the initiating device reads the `expired` status.
|
||||
:::
|
||||
|
||||
### Response
|
||||
|
||||
| Status | Body | Condition |
|
||||
| --- | --- | --- |
|
||||
| 204 | empty | The handoff was discarded, or no handoff existed for that code |
|
||||
| 400 | [error response](/http-api/#error-response) | The code is malformed, returning `INVALID_HANDOFF_CODE` |
|
||||
| 204 | empty | The handoff was discarded |
|
||||
| 400 | [error response](/http-api/#error-response) | The code is malformed, or the secret does not match, returning `INVALID_HANDOFF_CODE` |
|
||||
| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | Route or global bucket denies the request |
|
||||
| 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs |
|
||||
| 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling |
|
||||
|
||||
@@ -6,12 +6,12 @@ description: Checkout, card preapproval, gift purchase, age verification, refund
|
||||
|
||||
import RouteHeader from '@/components/RouteHeader.astro';
|
||||
|
||||
These routes take payment for premium, verify an account holder's age, and refund the most recent purchase. Each finishes on the payment provider's own pages, so a route here creates the provider session and returns its URL for the browser to open. [Receive Stripe webhook](#receive-stripe-webhook) takes the signed events the provider sends back. The [Premium resource](/http-api/premium/) owns entitlement state, mirrored billing data and subscription self-service.
|
||||
These routes take payment for premium, verify an account holder's age, and refund the most recent purchase. Checkout, card preapproval and age verification each finish on the payment provider's own pages, so those routes create the provider session and return its URL for the browser to open. [Receive Stripe webhook](#receive-stripe-webhook) takes the signed events the provider sends back. The [Premium resource](/http-api/premium/) owns entitlement state, mirrored billing data and subscription self-service.
|
||||
|
||||
Every route here is hosted-only, as [deployment availability](/http-api/deployment-availability/) describes. All of them are user-only except [receive Stripe webhook](#receive-stripe-webhook) and [continue localised card preapproval](#continue-localised-card-preapproval).
|
||||
|
||||
:::note[Every response is a Fluxer object]
|
||||
The responses are Fluxer redirect, eligibility, refund and acknowledgement objects. The only payment provider values in them are the identifiers on a [refund](#refund-object).
|
||||
The responses are Fluxer redirect, eligibility, refund and acknowledgement objects. They copy only a few payment provider values, such as the identifiers on a [refund](#refund-object) and the `invoice_id` of a [refund eligibility](#refund-eligibility-object) object.
|
||||
:::
|
||||
|
||||
## Redirect URL object
|
||||
@@ -36,7 +36,7 @@ One absolute URL that completes a billing operation in a browser. [Create subscr
|
||||
|
||||
## Localised card preapproval result object
|
||||
|
||||
The state of one card preapproval flow. A localised recurring price is offered only to a card issued in the matching country. Fluxer creates the paid session only after a separate setup mode session has proven the card country. `status` says which of the variants the object is, and each variant defines its own members.
|
||||
The state of one card preapproval flow. A localised recurring price is offered only to a card issued in the matching country. Fluxer creates the paid session only after a separate setup mode session, which takes no payment, has shown that the card was issued in the matching country. `status` says which of the variants the object is, and each variant defines its own members.
|
||||
|
||||
### Structure
|
||||
|
||||
@@ -67,7 +67,7 @@ The state of one card preapproval flow. A localised recurring price is offered o
|
||||
|
||||
| Value | Description |
|
||||
| --- | --- |
|
||||
| pending | The setup session has not completed, or a concurrent continuation is already resolving the same token |
|
||||
| pending | Setup has not completed, or another [continue localised card preapproval](#continue-localised-card-preapproval) call for this token is still running |
|
||||
| ready | The card was approved and the paid checkout URL is available |
|
||||
| rejected | The card was refused and the paid checkout cannot continue |
|
||||
| expired | The token is empty, unknown, or has passed its one-day lifetime |
|
||||
@@ -85,7 +85,7 @@ The state of one card preapproval flow. A localised recurring price is offered o
|
||||
|
||||
## Refund eligibility object
|
||||
|
||||
Whether the account's most recent purchase can still be refunded without operator involvement. When no refundable purchase was resolved, `eligible` is false, every invoice member is null, and `cooldown_expires_at` still reports an active cooldown unless the reason is `feature_unavailable`. A client reads `eligible` and `reason` before rendering any amount or timestamp.
|
||||
Whether the account's most recent purchase can still be refunded without operator involvement. When no refundable purchase was resolved, `eligible` and `cancels_subscription` are false. `invoice_id`, `invoice_amount_paid_cents`, `currency`, `paid_at` and `refund_window_expires_at` are null. `cooldown_expires_at` still reports an active cooldown unless the reason is `feature_unavailable`. A client reads `eligible` and `reason` before rendering any amount or timestamp.
|
||||
|
||||
### Structure
|
||||
|
||||
@@ -158,7 +158,7 @@ The refund that [refund latest purchase](#refund-latest-purchase) created agains
|
||||
| subscription_id<sup>3</sup> | ?string | Payment provider subscription that was cancelled along with the refund |
|
||||
| status<sup>4</sup> | ?string | Provider status of the refund |
|
||||
|
||||
<sup>1</sup> Zero until the provider confirms the refund succeeded. Once confirmed it is the amount the provider accepted, which is one minor unit short of `invoice_amount_paid_cents` in the Pix case
|
||||
<sup>1</sup> Zero until the provider confirms the refund succeeded. Once confirmed it is the amount the provider accepted, which is one minor unit short of `invoice_amount_paid_cents` when the charge was paid with Pix and the invoice amount is greater than one minor unit
|
||||
|
||||
<sup>2</sup> Reported as `usd` when the resolved invoice has no currency of its own
|
||||
|
||||
@@ -214,7 +214,7 @@ Creates a recurring premium checkout session and returns a [redirect URL](#redir
|
||||
- An unverified email address returns 403 `PURCHASE_EMAIL_VERIFICATION_REQUIRED`.
|
||||
- The purchase-disabled premium flag returns 403 `PREMIUM_PURCHASE_BLOCKED` with the reason `purchase_disabled`.
|
||||
- A lifetime Visionary account cannot buy a recurring subscription and returns 403 `PREMIUM_PURCHASE_BLOCKED` with the reason `lifetime`.
|
||||
- An account whose payment provider customer already holds a subscription in the `active`, `trialing`, `past_due`, `unpaid`, `incomplete` or `paused` state returns the same code with the reason `existing_subscription`, unless the conversion below applies.
|
||||
- An account whose payment provider customer already holds a subscription in the `active`, `trialing`, `past_due`, `unpaid`, `incomplete` or `paused` state returns the same code with the reason `existing_subscription`, unless Fluxer converts the purchase into a scheduled billing cycle change, as described below.
|
||||
|
||||
`reason` is a top-level member of the error response, and an `existing_subscription` refusal also has the blocking status in the top-level `subscription_status` member.
|
||||
|
||||
@@ -279,7 +279,7 @@ Creates a setup mode session that captures and verifies a card before a localise
|
||||
### Limitations
|
||||
|
||||
- The claimed account, verified email and purchase flag requirements of [create subscription checkout](#create-subscription-checkout) apply unchanged, with the same codes.
|
||||
- An unresolvable country, meaning a request Fluxer cannot geolocate that also omits `country_code`, and a resolved price that is not a recurring price in a currency other than USD and EUR each return 400 `STRIPE_INVALID_PRODUCT_CONFIGURATION`.
|
||||
- A request that Fluxer cannot geolocate and that omits `country_code` returns 400 `STRIPE_INVALID_PRODUCT_CONFIGURATION`. A resolved price that is not a recurring price in a currency other than USD and EUR also returns 400 `STRIPE_INVALID_PRODUCT_CONFIGURATION`.
|
||||
- The submitted price is always recurring, so the lifetime block and the existing subscription block both apply and return 403 `PREMIUM_PURCHASE_BLOCKED` with the reason `lifetime` or `existing_subscription`.
|
||||
|
||||
### JSON body
|
||||
@@ -323,7 +323,7 @@ The flow stays pending until [receive Stripe webhook](#receive-stripe-webhook) p
|
||||
|
||||
Reports the current state of a preapproval flow and creates the paid checkout session once the card is approved, returning a [localised card preapproval result](#localised-card-preapproval-result-object) object.
|
||||
|
||||
The continuation token is the credential, and no credential supplied on the request selects the flow.
|
||||
The continuation token is the credential. Fluxer selects the flow from the token alone, and a credential supplied on the request selects no flow.
|
||||
|
||||
An empty token, an unknown token, and a token whose one-day flow has expired are all reported as `expired`, so the operation never discloses whether a flow exists.
|
||||
|
||||
@@ -333,10 +333,10 @@ An empty token, an unknown token, and a token whose one-day flow has expired are
|
||||
- A recorded price that no longer belongs to the recorded country catalogue returns 400 `STRIPE_INVALID_PRODUCT_CONFIGURATION`.
|
||||
- The claimed account, verified email, purchase flag, lifetime and existing subscription refusals all apply to the account that opened the flow.
|
||||
|
||||
Creating the paid session repeats the complete preparation that [create subscription checkout](#create-subscription-checkout) does, against the account, price and country recorded on the flow.
|
||||
To create the paid session, Fluxer runs every check of [create subscription checkout](#create-subscription-checkout) and creates a checkout session the same way, using the account, price and country recorded on the flow.
|
||||
|
||||
:::caution[Approval is resolved once]
|
||||
One continuation at a time can resolve an approved flow. While one call creates the paid session, another call for the same flow is reported as `pending`.
|
||||
Only one call at a time creates the paid session for an approved flow. While one call creates the paid session, another call for the same flow is reported as `pending`.
|
||||
:::
|
||||
|
||||
### JSON body
|
||||
@@ -360,7 +360,7 @@ One continuation at a time can resolve an approved flow. While one call creates
|
||||
|
||||
A `pending`, `rejected` or `expired` result changes nothing. For an approved flow, Fluxer attempts to make the approved card the customer's default invoice payment method, then creates the paid checkout even when that update is unavailable.
|
||||
|
||||
It applies the checkout preparation described by [create subscription checkout](#create-subscription-checkout) with the approved price and country. The same token then always resolves to the same paid checkout URL. Entitlement changes only after the matching signed event reaches [receive Stripe webhook](#receive-stripe-webhook).
|
||||
Fluxer runs the checks of [create subscription checkout](#create-subscription-checkout) and creates the checkout session with the approved price and country. The same token then always resolves to the same paid checkout URL. Entitlement changes only after the matching signed event reaches [receive Stripe webhook](#receive-stripe-webhook).
|
||||
|
||||
### Rate limit
|
||||
|
||||
@@ -456,7 +456,7 @@ A deployment with no configured payment provider answers with `eligible` false a
|
||||
A failed payment-history lookup or an account with no payment history reports no refundable purchase.
|
||||
|
||||
:::note[The same object appears inside premium state]
|
||||
[Get premium state](/http-api/premium/#get-premium-state) also returns `billing.refund_eligibility`, which can lag behind this endpoint.
|
||||
[Get premium state](/http-api/premium/#get-premium-state) also returns `billing.refund_eligibility`, which Fluxer computes from the invoices it has stored. This endpoint reads the invoices from the payment provider, so the premium state value can be older.
|
||||
:::
|
||||
|
||||
### Response
|
||||
@@ -499,7 +499,7 @@ Once the provider confirms the refund succeeded, the subscription that produced
|
||||
|
||||
| Status | Body | Condition |
|
||||
| --- | --- | --- |
|
||||
| 200 | [refund](#refund-object) object | The refund was created, or the idempotency key resolved an earlier identical refund |
|
||||
| 200 | [refund](#refund-object) object | The refund was created, or the request repeats an earlier refund of this invoice and returns that refund |
|
||||
| 400<sup>1</sup> | [error response](/http-api/#error-response) | The account has no refundable purchase, or the payment provider cannot refund it |
|
||||
| 403 | [error response](/http-api/#error-response) | The purchase is outside the refund window, or the refund cooldown is active |
|
||||
| 404 | [error response](/http-api/#error-response) | The authenticated account record no longer exists |
|
||||
|
||||
@@ -8,7 +8,7 @@ import RouteHeader from '@/components/RouteHeader.astro';
|
||||
|
||||
A call is the live voice session of a direct message or a group direct message. [Channels](/http-api/channels/) defines the channel itself, its recipient set, and the region list a caller can select.
|
||||
|
||||
Joining and leaving a call is a main Gateway operation. [Voice State Update](/gateway/commands/#voice-state-update) requests the placement, and [Call Create](/gateway/events/#call-create), [Call Update](/gateway/events/#call-update), and [Call Delete](/gateway/events/#call-delete) publish the resulting call state.
|
||||
Joining and leaving a call is a main Gateway operation. A client joins or leaves a call by sending [Voice State Update](/gateway/commands/#voice-state-update), and [Call Create](/gateway/events/#call-create), [Call Update](/gateway/events/#call-update), and [Call Delete](/gateway/events/#call-delete) publish the resulting call state.
|
||||
|
||||
Every route on this page is user-only. Bot and OAuth2 credentials are rejected.
|
||||
|
||||
@@ -68,7 +68,7 @@ Returns the [call eligibility object](#call-eligibility-object) for a direct mes
|
||||
|
||||
<sup>1</sup> A guild channel ID is rejected with 400 `INVALID_CHANNEL_TYPE_FOR_CALL`
|
||||
|
||||
A direct message reports `ringable` as false when the other recipient's incoming call policy excludes the caller. That policy is the `incoming_call_flags` bitfield of the recipient's [user settings](/http-api/users/settings/). Fluxer reads the nobody flag, the friends-only flag, an existing friendship, a mutual friend, a mutual guild, and finally the everyone flag, in that order. The silent-everyone flag reports `silent` as true for a caller admitted by the mutual friend, mutual guild, or everyone branch.
|
||||
A direct message reports `ringable` as false when the other recipient's incoming call policy excludes the caller. That policy is the `incoming_call_flags` bitfield of the recipient's [user settings](/http-api/users/settings/). Fluxer checks the policy in this order. The nobody flag rejects every caller. The friends-only flag admits only a friend. Under any other policy a friend is always admitted. A caller who shares a friend with the recipient is admitted when the friends-of-friends flag is set. A caller who shares a guild with the recipient is admitted when the guild-members flag is set. The everyone flag admits every remaining caller. Any other caller is rejected. The silent-everyone flag reports `silent` as true for a caller admitted by the mutual friend, mutual guild, or everyone branch.
|
||||
|
||||
For a recipient who has never stored settings, `ringable` is true and `silent` is false. A direct message that has lost its other recipient reports the same pair. In a group direct message `ringable` is true unless the caller is already connected to its call, and `silent` is always false.
|
||||
|
||||
@@ -157,7 +157,7 @@ Starts a direct message or group direct message call, or adds ringing recipients
|
||||
|
||||
- The caller must satisfy the [access rules](#access-rules).
|
||||
- Every explicitly named recipient must be a current recipient other than the caller.
|
||||
- A direct message also requires the caller to satisfy the direct message send policy against the other recipient.
|
||||
- A direct message also requires that the caller is allowed to send the other recipient a direct message.
|
||||
|
||||
### Path parameters
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ An absent field is one this channel type does not own. A field present with `nul
|
||||
A category owns no `parent_id`. A client that reads the absent key as `null` sees a channel whose parent was cleared.
|
||||
|
||||
:::caution[Age and warning fields report this channel alone]
|
||||
The API enforces the resolved state, so a client must apply the inherited category state before it shows an age restriction or a content warning.
|
||||
The API enforces the value resolved through this channel, then its parent category, then the guild, as described below. A client must resolve the value in the same order before it shows an age restriction or a content warning.
|
||||
:::
|
||||
|
||||
An age restriction and a content warning both resolve through this channel first, then the parent category, and finally the guild. A category has no parent category and resolves through itself and then the guild.
|
||||
@@ -221,7 +221,7 @@ Returns the [channel object](#channel-object) visible to the authenticated user.
|
||||
- A private channel requires current recipient access.
|
||||
- The personal notes channel requires ownership.
|
||||
|
||||
Fluxer enforces age verification only for a guild text, voice, or link channel. A guild category is never gated on it, even when it has the override its children inherit.
|
||||
Fluxer enforces age verification only for a guild text, voice, or link channel. Fluxer never requires age verification for a guild category, even when the category has the override its children inherit.
|
||||
|
||||
### Path parameters
|
||||
|
||||
@@ -453,9 +453,9 @@ Fluxer trims a stored nickname, and a value that is null or empty after trimming
|
||||
|
||||
A guild channel change emits [Channel Update](/gateway/events/#channel-update) to every session subscribed to its guild. Replacing a category's permission overwrites also replaces them on each child whose overwrites still exactly match the category's previous values, with one Dispatch per changed child. A child whose overwrites had differed is left unchanged.
|
||||
|
||||
Changing `rate_limit_per_user` clears the channel's current slowmode state so the new interval applies immediately. Changing `rtc_region` on a guild voice channel moves its active voice connections to the selected region.
|
||||
Changing `rate_limit_per_user` clears the remaining slowmode delay of every user in the channel, so each user's next message is checked against the new interval. Changing `rtc_region` on a guild voice channel moves its active voice connections to the selected region.
|
||||
|
||||
A request that changes at least one field creates a channel update audit log entry with no reason. Supplying `permission_overwrites` also creates the overwrite audit log entries the change implies.
|
||||
A request that changes at least one field creates a channel update audit log entry with no reason. Supplying `permission_overwrites` also creates one overwrite create, update, or delete audit log entry for each overwrite the array adds, changes, or removes.
|
||||
|
||||
A group direct message change emits [Channel Update](/gateway/events/#channel-update) to every current recipient. A name or successful icon change creates a system message delivered with [Message Create](/gateway/events/#message-create), and replacing the icon permanently deletes the previous one. Ownership and nickname changes create no system message and no audit entry.
|
||||
|
||||
@@ -542,7 +542,7 @@ An account that stores neither a password nor a second factor is verified withou
|
||||
|
||||
Deleting a guild category first clears the `parent_id` of every child channel and emits [Channel Update](/gateway/events/#channel-update) for each one. The category itself is then deleted like any other guild channel.
|
||||
|
||||
Deleting a guild channel permanently removes its messages, attachments, invites and webhooks. Guild subscribers receive [Channel Delete](/gateway/events/#channel-delete), and the deletion appears in the audit log. A system, rules or AFK channel reference is cleared with [Guild Update](/gateway/events/#guild-update).
|
||||
Deleting a guild channel permanently removes its messages, attachments, invites and webhooks. Guild subscribers receive [Channel Delete](/gateway/events/#channel-delete), and the deletion appears in the audit log. When the deleted channel is the guild's system, rules or AFK channel, Fluxer clears that guild setting and emits [Guild Update](/gateway/events/#guild-update).
|
||||
|
||||
Closing a direct message marks the channel closed for the caller alone and emits [Channel Delete](/gateway/events/#channel-delete) to that caller. The channel, its messages, and the other recipient's view are untouched.
|
||||
|
||||
@@ -600,7 +600,7 @@ Fluxer evaluates the target's admission policy in this order.
|
||||
| Status | Body | Condition |
|
||||
| --- | --- | --- |
|
||||
| 204 | empty | Recipient was added, or was already a recipient |
|
||||
| 400 | [error response](/http-api/#error-response) | CAPTCHA proof is missing or rejected, the caller and target are not friends and the request returns `NOT_FRIENDS_WITH_USER` |
|
||||
| 400 | [error response](/http-api/#error-response) | CAPTCHA proof is missing or rejected, or the caller and target are not friends and the request returns `NOT_FRIENDS_WITH_USER` |
|
||||
| 400 | [error response](/http-api/#error-response) | The group is already full and the request returns `MAX_GROUP_DM_RECIPIENTS` |
|
||||
| 400 | [error response](/http-api/#error-response) | The channel is not a group direct message and the request returns `INVALID_CHANNEL_TYPE` |
|
||||
| 403 | [error response](/http-api/#error-response) | Caller is not a recipient, or the target's admission policy rejects the caller, each returning `MISSING_ACCESS` |
|
||||
@@ -735,7 +735,7 @@ A larger decimal string returns 400 `INVALID_FORM_BODY` with the code `INTEGER_O
|
||||
|
||||
### Side effects
|
||||
|
||||
Fluxer emits [Channel Update](/gateway/events/#channel-update) to every session subscribed to the guild, including when the submitted overwrite exactly matches the stored one. When the channel is a category, every child whose overwrites exactly matched the category's previous values receives the same replacement and its own Channel Update.
|
||||
Fluxer emits [Channel Update](/gateway/events/#channel-update) to every session subscribed to the guild, including when the submitted overwrite exactly matches the stored one. When the channel is a category, every child whose overwrites exactly matched the category's previous values receives a copy of the category's new overwrites and its own Channel Update.
|
||||
|
||||
The operation records an overwrite create or update audit entry with no reason. An unchanged overwrite records no audit entry.
|
||||
|
||||
@@ -774,7 +774,7 @@ The operation is idempotent, does not require `VIEW_CHANNEL`, and never returns
|
||||
|
||||
### Side effects
|
||||
|
||||
Fluxer emits [Channel Update](/gateway/events/#channel-update) to every session subscribed to the guild, including when the identifier names no existing overwrite. Removing an overwrite that exists also records an overwrite delete audit entry with the previous overwrite state and no reason. An identifier that names no existing overwrite records no audit entry. When the target channel is a category, propagation follows the same exact-match rule as overwrite replacement.
|
||||
Fluxer emits [Channel Update](/gateway/events/#channel-update) to every session subscribed to the guild, including when the identifier names no existing overwrite. Removing an overwrite that exists also records an overwrite delete audit entry with the previous overwrite state and no reason. An identifier that names no existing overwrite records no audit entry. When the target channel is a category, each child whose overwrites exactly matched the category's previous overwrites receives a copy of the category's new overwrites and its own Channel Update.
|
||||
|
||||
### Rate limit
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import RouteHeader from '@/components/RouteHeader.astro';
|
||||
|
||||
A connection links a Fluxer account to a verified domain or Bluesky account. Visible connections appear in `connected_accounts` on the [full user profile object](/http-api/users/#full-user-profile-object).
|
||||
|
||||
Connection routes require a user token. Bot tokens and OAuth2 bearers receive 403 `ACCESS_DENIED`, except that [List connections](#list-connections) accepts a bearer with the `connections` [scope](/http-api/oauth2/#oauth2-scopes). Accounts with an outstanding required action receive 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`. Connection IDs are not snowflakes.
|
||||
Connection routes require a user token. Bot tokens and OAuth2 bearers receive 403 `ACCESS_DENIED`, except that [List connections](#list-connections) accepts a bearer with the `connections` [scope](/http-api/oauth2/#oauth2-scopes). Accounts with an outstanding [required action](/http-api/users/#required-actions) receive 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`. Connection IDs are not snowflakes.
|
||||
|
||||
[Bluesky client metadata](#get-bluesky-client-metadata) and [JWKS](#get-bluesky-jwks) are public and contain no account data.
|
||||
|
||||
@@ -37,7 +37,7 @@ The pair of [type](#connection-types) and `id` identifies a connection. Both val
|
||||
|
||||
<sup>2</sup> For a `domain` connection the name is the submitted domain. For a `bsky` connection it is the Bluesky handle, refreshed by completing the [authorisation flow](#start-bluesky-authorisation)
|
||||
|
||||
<sup>3</sup> Starts true. A failed ownership recheck sets it to false
|
||||
<sup>3</sup> Fluxer sets it to true when the proof succeeds and again each time a Bluesky authorisation completes. No later check sets it to false
|
||||
|
||||
<sup>4</sup> The stored value is the integer the client supplied and is not masked against the defined bits
|
||||
|
||||
@@ -116,7 +116,7 @@ Returned by [Start Bluesky authorisation](#start-bluesky-authorisation).
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| authorize_url | string | URL to open for authorisation. Its origin depends on the account |
|
||||
| authorize_url | string | URL to open for authorisation. Its origin depends on the submitted `handle` |
|
||||
|
||||
## List connections
|
||||
|
||||
@@ -317,7 +317,7 @@ Assigns the display order of the listed connections from their position in the a
|
||||
|
||||
Each named connection receives its zero-based array index as `sort_order`. The complete list is sent to the caller's sessions in [User Connections Update](/gateway/events/#user-connections-update), even if the order did not change.
|
||||
|
||||
The reorder is atomic. A rejected write changes no order and emits no event. A later notification failure can return an error after the order has been saved.
|
||||
The reorder is atomic. A request that returns 409 `CONFLICT` changes no order and emits no event. When Fluxer saves the order and then fails to send [User Connections Update](/gateway/events/#user-connections-update), the request returns an error and the new order stays saved.
|
||||
|
||||
A partial array can leave two connections sharing a `sort_order`, which [List connections](#list-connections) resolves by stored order.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ title: Deployment availability
|
||||
description: The hosted-only routes and the instance flags that report deployment kind.
|
||||
---
|
||||
|
||||
A small set of routes exists only on the deployment Fluxer hosts. An operator runs the same release, and most of the HTTP API is identical on both.
|
||||
The routes listed under [Hosted-only routes](#hosted-only-routes) exist only on the deployment Fluxer hosts. A self-hosted deployment runs the same release, and most of the HTTP API is identical on both.
|
||||
|
||||
A self-hosted deployment does not register those routes. A request to one returns 404 `NOT_FOUND` with no feature-specific code, so a caller cannot tell an unavailable route from an unrecognised path.
|
||||
|
||||
@@ -14,13 +14,13 @@ Credentials, permissions, premium state and OAuth2 scopes do not change route av
|
||||
|
||||
Every deployment reports its kind in `self_hosted` on the [instance features object](/http-api/instance/#instance-features-object). The unauthenticated [instance discovery document](/http-api/instance/#get-instance-discovery) publishes it before a client holds any credential. `self_hosted` alone decides whether the API registers the routes below.
|
||||
|
||||
`stripe_enabled` on the same object reports the payment provider toggle alone. A hosted deployment that reports it false still serves every route in the table below. A deployment reporting `stripe_enabled` true with no provider secret key configured behaves exactly like one reporting it false.
|
||||
`stripe_enabled` on the same object reports only the `integrations.stripe.enabled` configuration value. A hosted deployment that reports it false still serves every route in the table below. A deployment reporting `stripe_enabled` true with no provider secret key configured behaves exactly like one reporting it false.
|
||||
|
||||
:::caution[Read `self_hosted` for the deployment kind]
|
||||
Neither flag promises that a provider-dependent operation succeeds.
|
||||
:::
|
||||
|
||||
Without a provider client, the answer depends on the operation. An operation that has to reach the provider fails with 400 `STRIPE_PAYMENT_NOT_AVAILABLE`, and [Receive Stripe webhook](/http-api/billing/#receive-stripe-webhook) fails with 400 `STRIPE_WEBHOOK_NOT_AVAILABLE`. These read operations report the absence in a 200 body instead:
|
||||
When `stripe_enabled` is false or no provider secret key is configured, the answer depends on the operation. An operation that has to reach the provider fails with 400 `STRIPE_PAYMENT_NOT_AVAILABLE`, and [Receive Stripe webhook](/http-api/billing/#receive-stripe-webhook) fails with 400 `STRIPE_WEBHOOK_NOT_AVAILABLE`. These read operations report the absence in a 200 body instead:
|
||||
|
||||
- [Get refund eligibility](/http-api/billing/#get-refund-eligibility) reports `eligible` false with the reason `feature_unavailable`.
|
||||
- [Get current subscription price](/http-api/premium/#get-current-subscription-price) reports null.
|
||||
@@ -68,4 +68,4 @@ A route every deployment registers can still produce a different answer on a sel
|
||||
|
||||
Premium state is the clearest case. Every deployment registers [Get premium state](/http-api/premium/#get-premium-state) and [Set premium perks disabled](/http-api/premium/#set-premium-perks-disabled). A self-hosted instance still reports premium state and still records the perks-disabled flag. The response repeats the deployment kind in `self_hosted` on the [effective premium state object](/http-api/premium/#effective-premium-state-object). That flag alone does not make `is_premium` true. A self-hosted deployment grants premium to every account only while its instance [premium mode](/admin-api/instance/#premium-modes) is `everyone`. That mode also overrides the perks-disabled flag, so `is_premium` stays true while `premium_perks_disabled` is true.
|
||||
|
||||
The other instance feature flags published by [instance discovery](/http-api/instance/#instance-features-object) work the same way. `voice_enabled`, `presigned_attachment_uploads`, and `emails_enabled` each switch off a capability that the surrounding routes still expose, so a client reads the flag.
|
||||
The other instance feature flags published by [instance discovery](/http-api/instance/#instance-features-object) work the same way. `voice_enabled`, `presigned_attachment_uploads`, and `emails_enabled` each report whether a capability is switched on. The routes for that capability stay registered when the flag is false, so a client reads the flag before it uses them.
|
||||
|
||||
@@ -82,7 +82,7 @@ One page of matching listings, together with the total and the per-category coun
|
||||
| total<sup>1</sup> | integer | The total number of guilds matching the query |
|
||||
| category_counts<sup>2</sup> | array[[discovery category count](#discovery-category-count-object) object] | The match count for each category under the current filters |
|
||||
|
||||
<sup>1</sup> The count describes the complete match set, so it bounds paging through `offset`
|
||||
<sup>1</sup> The count covers every match on every page. A client pages with `offset` until `offset` reaches `total`
|
||||
|
||||
<sup>2</sup> Computed with the `category` filter removed and every other filter applied, so the counts describe what selecting a different category would return. A category with no match is omitted, and the array is ordered by ascending category
|
||||
|
||||
@@ -358,7 +358,7 @@ A pending, rejected, removed, or absent application fails with 400 `DISCOVERY_NO
|
||||
| 403 | [error response](/http-api/#error-response) | The caller is banned from the guild directly or by address and the request returns `USER_BANNED_FROM_GUILD` or `USER_IP_BANNED_FROM_GUILD` |
|
||||
| 404 | [error response](/http-api/#error-response) | The approved application names a guild whose record no longer exists and the request returns `UNKNOWN_GUILD` |
|
||||
|
||||
<sup>1</sup> An account whose phone requirement was deferred is re-evaluated against the target guild at join time, so an account that satisfies the standing check can still be refused here
|
||||
<sup>1</sup> An account without a verified phone number can have a phone requirement that Fluxer holds back until the account joins a guild. At join time Fluxer checks that requirement against the target guild. An account that passes the account-wide check can therefore still receive `ACCOUNT_SUSPICIOUS_ACTIVITY` here
|
||||
|
||||
:::note[A guild that never existed returns 400, not 404]
|
||||
A guild with no approved listing returns `DISCOVERY_NOT_DISCOVERABLE`.
|
||||
@@ -368,9 +368,9 @@ A guild with no approved listing returns `DISCOVERY_NOT_DISCOVERABLE`.
|
||||
|
||||
An account that is already a member receives the same 204 response with no Dispatch. Otherwise the operation creates the membership, records discovery as its join source, and adds the guild to the caller's settings and folder layout.
|
||||
|
||||
The joining account's sessions receive [Guild Create](/gateway/events/#guild-create). [Guild Member Add](/gateway/events/#guild-member-add) is dispatched guild-wide and reaches whichever sessions [event filtering](/gateway/event-filtering/) selects. A bot session always receives it, and a passive user session in a guild with more than 250 members does not receive it until [Lazy Request](/gateway/commands/#lazy-request) marks that guild active. The joining account receives [User Settings Update](/gateway/events/#user-settings-update) when the join changes its restricted guild set or its folder layout, and [User Guild Settings Update](/gateway/events/#user-guild-settings-update) when its account default hides muted channels.
|
||||
The joining account's sessions receive [Guild Create](/gateway/events/#guild-create). [Guild Member Add](/gateway/events/#guild-member-add) is dispatched guild-wide and reaches whichever sessions [event filtering](/gateway/event-filtering/) selects. A bot session always receives it, and a passive user session in a guild with more than 250 members does not receive it until [Lazy Request](/gateway/commands/#lazy-request) marks that guild active. The joining account receives [User Settings Update](/gateway/events/#user-settings-update) when the join adds the guild to `restricted_guilds` in its [user settings](/http-api/users/settings/) or changes its folder layout, and [User Guild Settings Update](/gateway/events/#user-guild-settings-update) when its account default hides muted channels.
|
||||
|
||||
Unless join notifications are suppressed or no system channel is configured, the join emits a [USER_JOIN](/http-api/messages/#message-types) system message through [Message Create](/gateway/events/#message-create). No invite use is consumed.
|
||||
Unless the guild sets the `SUPPRESS_JOIN_NOTIFICATIONS` [system channel flag](/http-api/guilds/#system-channel-flags) or has no system channel, the join emits a [USER_JOIN](/http-api/messages/#message-types) system message through [Message Create](/gateway/events/#message-create). No invite use is consumed.
|
||||
|
||||
### Rate limit
|
||||
|
||||
@@ -483,7 +483,7 @@ Every field is optional, and an omitted field preserves the stored value.
|
||||
|
||||
### Side effects
|
||||
|
||||
Editing preserves the listing's status and review details. Approved changes appear in [Search discovery guilds](#search-discovery-guilds), while pending listings remain absent. No guild feature changes.
|
||||
Editing preserves the listing's status and review details. Changes to an approved listing appear in [Search discovery guilds](#search-discovery-guilds). A pending listing stays absent from search. No guild feature changes.
|
||||
|
||||
### Rate limit
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ import RouteHeader from '@/components/RouteHeader.astro';
|
||||
|
||||
A donation is a one-off or recurring payment, taken on an externally hosted checkout page. Fluxer tracks it by email address, and it grants no [premium](/http-api/premium/). [Billing](/http-api/billing/) defines the payment provider webhook that completes one.
|
||||
|
||||
None of the routes here takes a credential, and all are hosted-only, as [deployment availability](/http-api/deployment-availability/) describes. A valid credential presented anyway keys the rate limit to the account.
|
||||
None of the routes here takes a credential, and all are hosted-only, as [deployment availability](/http-api/deployment-availability/) describes. When a request presents a valid credential anyway, Fluxer counts the request against that account's rate limit.
|
||||
|
||||
Fluxer uses a submitted address exactly as written and matches a donor by exact equality, so `[email protected]` and `[email protected]` address two different donors.
|
||||
|
||||
:::note[Donation management requires a completed donation]
|
||||
Donation management becomes available after payment is confirmed.
|
||||
Fluxer stores a donor only when the payment provider confirms a donation checkout. [Request donation management link](#request-donation-management-link) sends a link only to an address stored as a donor.
|
||||
:::
|
||||
|
||||
## Donation currencies
|
||||
@@ -96,7 +96,7 @@ An address that resolves to no donor receives no email.
|
||||
|
||||
### Side effects
|
||||
|
||||
An eligible donor receives an email. The 204 response does not guarantee delivery.
|
||||
An address that resolves to a donor receives an email with the management link. The 204 response does not guarantee delivery.
|
||||
|
||||
Fluxer creates a single-use token of 64 lowercase hexadecimal characters, valid for 15 minutes, only when the address resolves to a donor. Issuing a new link deletes every earlier token for the same address, and a replaced link returns 400 `DONATION_MAGIC_LINK_INVALID`.
|
||||
|
||||
@@ -176,7 +176,7 @@ A recurring donation for an address with an active recurring donation returns th
|
||||
|
||||
Checkout opens on the provider's pages for the submitted amount, currency and interval. Completion returns the donor to the public donation success page, while cancellation returns to the public donation page.
|
||||
|
||||
Payment confirmation sends an email and enables [Request donation management link](#request-donation-management-link).
|
||||
When the payment provider confirms the payment, Fluxer stores the address as a donor and sends a donation confirmation email to it. From then on, [Request donation management link](#request-donation-management-link) sends a link to that address.
|
||||
|
||||
### Rate limit
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ The download resource serves the desktop application builds a deployment has sto
|
||||
|
||||
Fluxer registers the routes under `/dl`, and the desktop routes under `/dl/desktop`. A client builds the URL against `api_client` from the [instance endpoints object](/http-api/instance/#instance-endpoints-object).
|
||||
|
||||
The prefix mounts at the root and at `/v1`, so `/v1/dl/desktop/...` resolves for the routes that name their own segments.
|
||||
Fluxer mounts the prefix at the root and at `/v1`. Every desktop route below also answers under `/v1/dl/desktop/...`.
|
||||
|
||||
:::caution[The catch-all has no `/v1` form]
|
||||
[Download stored object](#download-stored-object) requires the `/dl` prefix. Its `/v1/dl/...` equivalent returns 404.
|
||||
@@ -173,19 +173,19 @@ A release feed filename is any of these:
|
||||
|
||||
The deployment settings below answer a download with 302.
|
||||
|
||||
A hosted deployment can redirect downloads from selected countries to GitHub release assets. Responses under this setting have `Cache-Control: private, no-store`.
|
||||
A hosted deployment can list countries whose downloads of a file under `desktop/` Fluxer can redirect to GitHub release assets. Once the list is set, every response for such a file has `Cache-Control: private, no-store`, including a response Fluxer serves from storage.
|
||||
|
||||
A deployment that issues presigned download URLs answers a `GET` with 302 to a storage URL valid for 900 seconds. That redirect has `Cache-Control: no-store` and `Accept-Ranges: bytes`. The setting is off by default.
|
||||
|
||||
:::caution[A 302 has no bytes and no range]
|
||||
The client follows `Location` to read the file. Fluxer produces the redirect before it reads any `Range` header, so range handling on a redirected download belongs to the target.
|
||||
The client follows `Location` to read the file. Fluxer produces the redirect before it reads any `Range` header, so the server at `Location` answers any `Range` header on a redirected download.
|
||||
:::
|
||||
|
||||
## Test builds
|
||||
|
||||
Every route accepts the `test` query parameter. `1` or `true`, matched without regard to case, selects test releases. Any other value is false.
|
||||
|
||||
On the desktop routes the flag replaces the prefix outright. On [Download stored object](#download-stored-object) it rewrites a key beginning `desktop/` and leaves any other key unchanged, and a path that already names `desktop-test/` resolves there with no flag at all.
|
||||
On the desktop routes the flag makes Fluxer resolve every file under `desktop-test/`. On [Download stored object](#download-stored-object) it rewrites a key beginning `desktop/` and leaves any other key unchanged, and a path that already names `desktop-test/` resolves there with no flag at all.
|
||||
|
||||
A `url` and a `checksum_url` built for a request that sent the flag repeat `?test=1`, so a client following either one stays on the test prefix.
|
||||
|
||||
@@ -312,7 +312,7 @@ Streams the newest file at the coordinate for one package format.
|
||||
| 404 | `Not Found` | No file resolved for the format at the coordinate |
|
||||
| 416 | empty | The requested range is unsatisfiable |
|
||||
|
||||
The stored file keeps its own name, so the resolved filename has the release version even though the request named `latest`. A client that follows this route on every check reads a different file once the coordinate publishes a new release.
|
||||
The stored file keeps its own name, so the resolved filename has the release version even though the request named `latest`. A client that follows this route on every check reads a different file once a newer release is stored at the coordinate.
|
||||
|
||||
### Response headers
|
||||
|
||||
@@ -493,7 +493,7 @@ A path outside `desktop/` or `desktop-test/` returns 404.
|
||||
|
||||
Fluxer also rejects the key when it is empty, when normalisation leaves it beginning `..` or `/`, or when any segment is `.`, `..`, or contains a NUL character. Each of those returns the same 404, so a caller cannot tell a traversal attempt from a missing object.
|
||||
|
||||
Both `desktop/stable/linux-x64/manifest.json` and `desktop/stable/linux/x64/manifest.json` path forms are supported.
|
||||
A request can name `desktop/stable/linux-x64/manifest.json` or `desktop/stable/linux/x64/manifest.json`. For the hyphen form, Fluxer returns that object when storage holds it, and otherwise returns the object at the slash form.
|
||||
|
||||
### Response headers
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Every bound below is a fixed constant of the instance, and none is resolved from
|
||||
|
||||
<sup>2</sup> Only [Upload entrance sound](#upload-entrance-sound) applies the lower bound
|
||||
|
||||
The [instance discovery](/http-api/instance/#limit-keys) document publishes a `feature_voice_entrance_sounds` limit key. That key gates the feature in the client. No route on this page checks it, so a library can be read, written, and played back regardless of its resolved value.
|
||||
The [instance discovery](/http-api/instance/#limit-keys) document publishes a `feature_voice_entrance_sounds` limit key. The Fluxer client reads that key to decide whether it opens the clip upload dialog and whether it calls [Play entrance sound](#play-entrance-sound) after it connects to a voice channel. No route on this page checks it, so a library can be read, written, and played back regardless of its resolved value.
|
||||
|
||||
## Supported containers
|
||||
|
||||
@@ -316,7 +316,7 @@ Fluxer writes or removes the scope's selection. One clip can be selected in any
|
||||
|
||||
<RouteHeader method="POST" path="/v1/voice/channels/{channel_id}/entrance-sound" />
|
||||
|
||||
Fans the caller's chosen clip out to everyone else connected to a voice channel and returns 204 with an empty body. The caller must already hold a voice state in that channel and must own the clip. Emits an [ENTRANCE_SOUND_PLAY](/gateway/events/#entrance-sound-play) Gateway event.
|
||||
Tells every other account connected to a voice channel to play the caller's chosen clip and returns 204 with an empty body. The caller must already hold a voice state in that channel and must own the clip. Emits an [ENTRANCE_SOUND_PLAY](/gateway/events/#entrance-sound-play) Gateway event.
|
||||
|
||||
Recipients fetch and play the clip locally from the URL in the event.
|
||||
|
||||
@@ -336,7 +336,7 @@ Recipients fetch and play the clip locally from the URL in the event.
|
||||
|
||||
| Status | Body | Condition |
|
||||
| --- | --- | --- |
|
||||
| 204 | empty | Fan-out was attempted for every other connected account |
|
||||
| 204 | empty | Fluxer attempted a Dispatch to every other connected account |
|
||||
| 400 | [error response](/http-api/#error-response) | Caller holds no voice state in the channel, including when the channel does not exist, and the request returns `ENTRANCE_SOUND_INVALID_SCOPE` at the `channel_id` path, or the account owns no such clip and the request returns `ENTRANCE_SOUND_NOT_FOUND` at the `sound_id` path |
|
||||
|
||||
:::caution[Connection is the only authorisation]
|
||||
|
||||
@@ -68,7 +68,7 @@ A validation failure whose elements have enumerated codes answers 400 with its e
|
||||
|
||||
### Default schema failure codes
|
||||
|
||||
A boundary schema constraint can name its own [validation code](#validation-error-code-registry). When it names none, Fluxer maps the failure to one of the codes below by the kind of constraint that failed.
|
||||
A constraint in a route's request schema can name its own [validation code](#validation-error-code-registry). When it names none, Fluxer maps the failure to one of the codes below by the kind of constraint that failed.
|
||||
|
||||
| Constraint | Code | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -110,7 +110,7 @@ Fluxer answers an unrecognised failure with 500 `INTERNAL_SERVER_ERROR` and a ge
|
||||
|
||||
## Client errors as an abuse signal
|
||||
|
||||
Repeated invalid requests or credentials can trigger a temporary IP ban. A `4xx` answer to a request with no authenticated user adds to that signal, weighted by status. A 429 weighs 3, a 401 weighs 0.75, a 403 weighs 0.5, and every other 4xx weighs 0.25. One request adds at most one signal, and a request from a private or exempt address adds none. Stop using a rejected credential and respect rate-limit responses instead of retrying unchanged requests.
|
||||
Repeated invalid requests or credentials can trigger a temporary IP ban. A `4xx` answer to a request with no authenticated user adds to that signal, weighted by status. A 429 weighs 3, a 401 weighs 0.75, a 403 weighs 0.5, and every other 4xx weighs 0.25. One request adds at most one signal, and a request from a private or exempt address adds none. Stop using a rejected credential. Change a rejected request before sending it again, and after a 429 wait `retry_after` before the next attempt.
|
||||
|
||||
:::caution[An automatic ban answers every request for 24 hours]
|
||||
A temporary ban lasts 24 hours by default. Requests from the banned address return 403 `GLOBAL_IP_TEMPORARILY_BANNED`. Use `expires_at` from the response when available.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
title: Experiments
|
||||
description: The experiment assignments envelope, the revalidation and polling contract, and the noise suppression experiment.
|
||||
description: The experiment assignments envelope, the revalidation and polling contract, and the experiments this server defines.
|
||||
---
|
||||
|
||||
import RouteHeader from '@/components/RouteHeader.astro';
|
||||
|
||||
An experiment is one instance-wide rollout the operator configures and Fluxer resolves against one account. 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. `voice_noise_suppression` is the only experiment defined today, and [Voice](/voice/) defines the placement protocol its assignment applies to.
|
||||
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, and `blocked_message_groups`, which selects how a client renders a revealed block of blocked messages.
|
||||
|
||||
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.
|
||||
|
||||
@@ -19,10 +19,10 @@ One resolution of every defined experiment against one account. Every field is p
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| poll_interval_seconds | integer | Seconds to wait before revalidating, from 60 through 86400 |
|
||||
| poll_jitter_percent | integer | How far to spread the wait around the interval, from 0 through 50 |
|
||||
| poll_jitter_percent | integer | The largest random offset added to or subtracted from the wait, as a percentage of the wait, from 0 through 50 |
|
||||
| assignments | [assignment map](#assignment-map-object) object | One entry for each experiment the server defines |
|
||||
|
||||
The polling fields apply to every experiment and account on the instance.
|
||||
Every account on the instance receives the same `poll_interval_seconds` and `poll_jitter_percent`. One request refreshes every experiment.
|
||||
|
||||
## Assignment map object
|
||||
|
||||
@@ -33,10 +33,13 @@ One entry per experiment. The envelope reports this object even when it is empty
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| voice_noise_suppression? | [noise suppression assignment](#noise-suppression-assignment-object) object | The caller's noise suppression assignment |
|
||||
| message_hover_tracking? | [message hover tracking assignment](#message-hover-tracking-assignment-object) object | The caller's message hover tracking assignment |
|
||||
| message_keyboard_focus? | [message keyboard focus assignment](#message-keyboard-focus-assignment-object) object | The caller's message keyboard focus assignment |
|
||||
| blocked_message_groups? | [blocked message groups assignment](#blocked-message-groups-assignment-object) object | The caller's blocked message groups assignment |
|
||||
|
||||
Ignore unknown experiments and treat a missing experiment as off.
|
||||
|
||||
This server version writes `voice_noise_suppression` on every response, including while the 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 can tell an operator write from a no-op without a second request.
|
||||
This server version writes `voice_noise_suppression`, `message_hover_tracking`, `message_keyboard_focus`, and `blocked_message_groups` 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
|
||||
|
||||
@@ -91,7 +94,7 @@ A client branches on `user_targeted` rather than on `enabled_backends`, because
|
||||
|
||||
## Noise suppression guild override object
|
||||
|
||||
One backend replacement scoped to one guild. A guild named here replaces `backend` while the caller is connected to a voice channel of that guild.
|
||||
One backend replacement scoped to one guild. While the caller is connected to a voice channel of that guild, the override's `backend` replaces the assignment's `backend`.
|
||||
|
||||
### Structure
|
||||
|
||||
@@ -102,6 +105,87 @@ One backend replacement scoped to one guild. A guild named here replaces `backen
|
||||
|
||||
An override naming a backend that is absent from `enabled_backends` is dropped before the response is written, so every entry is runnable.
|
||||
|
||||
## Message hover tracking assignment object
|
||||
|
||||
One resolution of the instance message hover tracking rollout against one account. Every field is present whenever the key is written.
|
||||
|
||||
The rollout selects which implementation of the message hover state a client runs. A drawn client resolves the hovered message from one shared pointer oracle and drives the message action bar from that state. A client that is not drawn keeps the per-row implementation it ships with. 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 |
|
||||
|
||||
### Hover tracking 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.
|
||||
|
||||
## Message keyboard focus assignment object
|
||||
|
||||
One resolution of the instance message keyboard focus rollout against one account. Every field is present whenever the key is written.
|
||||
|
||||
The rollout selects which implementation of keyboard navigation a client runs in the message list. A drawn client reaches the message list from the composer with one Tab, walks messages with the arrow keys through revealed blocked groups, and draws the focus ring inside each row. A client that is not drawn keeps the implementation it ships with. 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 |
|
||||
|
||||
### Keyboard navigation 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.
|
||||
|
||||
## Blocked message groups assignment object
|
||||
|
||||
One resolution of the instance blocked message groups rollout against one account. Every field is present whenever the key is written.
|
||||
|
||||
The rollout selects how a client renders a revealed block of blocked or suspected spam messages. A drawn client draws the block full width, spaces consecutive message groups inside it, and keys an unread divider apart from the group below it. A client that is not drawn keeps the rendering it ships with. 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 |
|
||||
|
||||
### Blocked message groups 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 />
|
||||
|
||||
@@ -22,7 +22,7 @@ Every field is always present.
|
||||
| shards | integer | Recommended shard count, always `1` |
|
||||
| session_start_limit | [session start limit](#session-start-limit-object) object | Fixed session start values |
|
||||
|
||||
Fluxer appends no query string, so a client appends the [connection parameters](/gateway/overview/#connection-parameters) itself. A client MUST NOT upgrade a published `ws` value, because a deliberately plain HTTP deployment advertises one.
|
||||
Fluxer appends no query string, so a client appends the [connection parameters](/gateway/overview/#connection-parameters) itself. A client MUST NOT rewrite a published `ws` value to `wss`, because a deployment that deliberately serves plain HTTP advertises a `ws` value.
|
||||
|
||||
### Example
|
||||
|
||||
@@ -56,7 +56,7 @@ These are fixed compatibility values, not live usage counters.
|
||||
|
||||
<sup>1</sup> A bot does not need to pace Identify requests against this value
|
||||
|
||||
The limits the Gateway enforces are in [Session lifecycle](/gateway/limits-and-rate-limits/#session-lifecycle). Fluxer budgets Identify per source address and caps a user account at a fixed number of concurrent sessions. Neither bound is reported here.
|
||||
The limits the Gateway enforces are in [Session lifecycle](/gateway/limits-and-rate-limits/#session-lifecycle). Fluxer rate limits Identify per source address and limits a user account to a fixed number of concurrent sessions. Neither bound is reported here.
|
||||
|
||||
:::caution[A remaining session start does not guarantee admission]
|
||||
These values reserve no capacity. [Session admission](/gateway/limits-and-rate-limits/#session-lifecycle) can still hold or reject a connection.
|
||||
@@ -76,7 +76,7 @@ The route checks the shape of the credential only. A well-formed value naming no
|
||||
|
||||
Validate a token with [Identify](/gateway/commands/#identify) or [Get bot application](/http-api/applications/#get-bot-application) instead.
|
||||
|
||||
A 200 has the informational [rate limit headers](/topics/rate-limits/#rate-limit-headers) only for the `Bot ` prefix. Fluxer keys the bare form and the `Bearer ` form on the client IP address.
|
||||
A 200 has the informational [rate limit headers](/topics/rate-limits/#rate-limit-headers) only for the `Bot ` prefix. For the bare form and the `Bearer ` form, Fluxer keys the rate limit bucket on the client IP address.
|
||||
|
||||
### Response
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Every route requires a session credential. A bot token and an OAuth2 bearer cred
|
||||
|
||||
## Provider availability
|
||||
|
||||
`klipy` is the only provider name a deployment binds. [Get instance discovery](/http-api/instance/#get-instance-discovery) publishes it as the [GIF provider](/http-api/instance/#gif-provider-object) object. An instance that has bound no provider key refuses every route on this page with 403 `FEATURE_TEMPORARILY_DISABLED`. That same key drives `gif_enabled` in [service availability](/http-api/instance/#service-availability-object), a flag an operator can also set by hand, so a client that reads `gif_enabled` as true can still receive the 403.
|
||||
`klipy` is the only provider name a deployment binds. [Get instance discovery](/http-api/instance/#get-instance-discovery) publishes it as the [GIF provider](/http-api/instance/#gif-provider-object) object. An instance that has bound no provider key refuses every route on this page with 403 `FEATURE_TEMPORARILY_DISABLED`. `gif_enabled` in [service availability](/http-api/instance/#service-availability-object) reports whether that key is bound, unless an operator has set the flag by hand. A client that reads `gif_enabled` as true can therefore still receive the 403.
|
||||
|
||||
A provider outage, a request past its deadline, and an unreadable provider payload all return 503 `SERVICE_UNAVAILABLE`. The deadline is 12 seconds everywhere except [Register a GIF share](#register-a-gif-share), which uses 3 seconds. An operator can change both.
|
||||
|
||||
@@ -36,7 +36,7 @@ Every response under `/gifs`, `/tenor`, and `/klipy` has these headers, includin
|
||||
|
||||
## Result freshness
|
||||
|
||||
Results may be cached and can lag behind the provider. Repeating a request does not force a refresh.
|
||||
Fluxer caches results, so a result can lag behind the provider. Repeating a request does not force a refresh.
|
||||
|
||||
## GIF object
|
||||
|
||||
@@ -62,7 +62,7 @@ A GIF object is one media item the active provider owns. Every URL in it resolve
|
||||
|
||||
<sup>2</sup> Copied from the `webm` entry of `media` when the provider returned one, and from the first entry it returned otherwise
|
||||
|
||||
<sup>3</sup> Empty when no format the provider returned could be proxied. No key is guaranteed, so a client walks a priority list
|
||||
<sup>3</sup> Empty when no format the provider returned could be proxied. No key is guaranteed, so a client checks the format names it prefers in order and uses the first one present
|
||||
|
||||
<sup>4</sup> The active provider emits none, so the field is absent from every GIF these routes return. [Resolve GIF URLs](/http-api/memes/#resolve-gif-urls) is the operation that produces one
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ A gift records no recipient, so Fluxer binds the code to whichever eligible acco
|
||||
|
||||
## Gift object
|
||||
|
||||
A gift records a duration. Fluxer computes the entitlement window at redemption time from the redeemer's existing state.
|
||||
A gift records a duration. Fluxer computes the entitlement window at redemption time. For a positive quantity, the entitlement anchor is the latest of the current time, the redeemer's current premium end and their existing gift extension end.
|
||||
|
||||
Both creation paths record a creator. A completed gift checkout records the purchaser. An Admin API gift records the system account with ID `0`, and no field names the administrator that requested it. No operation unredeems a code.
|
||||
|
||||
@@ -134,7 +134,7 @@ One redemption can be in flight for a code across the whole deployment, and a se
|
||||
| X-Captcha-Token?<sup>1</sup> | string | The proof issued by the CAPTCHA provider |
|
||||
| X-Captcha-Type?<sup>2</sup> | string | The CAPTCHA provider, either `hcaptcha` or `turnstile` |
|
||||
|
||||
<sup>1</sup> A missing proof returns 400 `CAPTCHA_REQUIRED` and a rejected proof returns 400 `INVALID_CAPTCHA`. Verification is skipped when CAPTCHA is disabled, when the account has the exemption flag, or when the caller's contact has the exemption capability, as described by [CAPTCHA handling](/topics/captcha/)
|
||||
<sup>1</sup> A missing proof returns 400 `CAPTCHA_REQUIRED` and a rejected proof returns 400 `INVALID_CAPTCHA`. Verification is skipped when CAPTCHA is disabled, when the account has the exemption flag, or when the account's email address has the `captcha_exempt` account policy capability, as described by [CAPTCHA handling](/topics/captcha/)
|
||||
|
||||
<sup>2</sup> Any other value, including an omitted header, falls back to the instance's configured provider
|
||||
|
||||
@@ -143,7 +143,7 @@ One redemption can be in flight for a code across the whole deployment, and a se
|
||||
| Status | Body | Condition |
|
||||
| --- | --- | --- |
|
||||
| 204 | empty | The gift was redeemed and the entitlement was applied |
|
||||
| 400 | [error response](/http-api/#error-response) | CAPTCHA failed, the code is redeemed or a redemption is in flight, the account is unclaimed or holds lifetime entitlement, the payment provider rejected the subscription work, or the Visionary guild join for a lifetime gift failed |
|
||||
| 400 | [error response](/http-api/#error-response) | CAPTCHA failed, the code is redeemed or a redemption is in flight, the account is unclaimed or holds lifetime entitlement, the payment provider rejected the change to the active subscription, or the Visionary guild join for a lifetime gift failed |
|
||||
| 403 | [error response](/http-api/#error-response) | The email address is unverified, or purchases are disabled for the account |
|
||||
| 404 | [error response](/http-api/#error-response) | No gift exists for the code, the gift was revoked, the authenticated account record no longer exists, or the configured Visionary guild does not exist |
|
||||
|
||||
@@ -151,9 +151,9 @@ One redemption can be in flight for a code across the whole deployment, and a se
|
||||
|
||||
The gift becomes redeemed, so [Get gift](#get-gift) reports `redeemed` as true and [List current user gifts](/http-api/users/gifts/#list-current-user-gifts) shows the redemption time and the redeemer to the buyer.
|
||||
|
||||
A positive quantity extends premium from the latest of the current time, the current premium end and the existing gift extension end. An eligible active subscription also has its trial or billing period extended without proration. A provider failure can return 400 `STRIPE_ERROR` and leave the code unredeemed.
|
||||
A positive quantity extends premium from the latest of the current time, the current premium end and the existing gift extension end. When the account has a subscription with the payment provider and its subscription premium has not ended, Fluxer also extends that subscription's trial or billing period without proration. A provider failure can return 400 `STRIPE_ERROR` and leave the code unredeemed.
|
||||
|
||||
A quantity of `0` grants lifetime Visionary premium and immediately cancels an active subscription without proration or a final invoice. It can also assign a Visionary sequence and join the [Visionary guild](/http-api/premium/#rejoin-visionary-guild).
|
||||
A quantity of `0` grants lifetime Visionary premium and immediately cancels an active subscription without proration or a final invoice. When the gift record has a Visionary sequence number, the redeemer receives that number as their lifetime Visionary sequence and joins the [Visionary guild](/http-api/premium/#rejoin-visionary-guild).
|
||||
|
||||
A failed Visionary guild join leaves the gift unredeemed. Guild limits return 400 `MAX_GUILDS` or `MAX_GUILD_MEMBERS`. Any subscription cancellation already completed is not reversed.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ An audit log entry records one change made to a guild and the account that made
|
||||
|
||||
## Audit log reason
|
||||
|
||||
An audit-capable route accepts the `X-Audit-Log-Reason` request header, listed with the other [standard request headers](/http-api/#standard-request-headers). Fluxer reads the value verbatim and never percent-decodes it, so a caller that percent-encodes the reason stores and reads back the percent-encoded form.
|
||||
A route marked Audit reason accepts the `X-Audit-Log-Reason` request header, listed with the other [standard request headers](/http-api/#standard-request-headers). Fluxer reads the value verbatim and never percent-decodes it, so a caller that percent-encodes the reason stores and reads back the percent-encoded form.
|
||||
|
||||
Fluxer trims the value. A value that is blank before trimming, empty after it, or longer than 512 characters after it counts as no reason at all. None of those values fails the request, so a reason that misses the bound is dropped and the operation still succeeds.
|
||||
|
||||
@@ -197,7 +197,7 @@ Fluxer serialises a permission mask or a snowflake as a decimal string. A bounde
|
||||
:::
|
||||
|
||||
:::caution[A change list holds only the differing fields]
|
||||
An update records one change object per differing field, so an unchanged field is absent. An identity field never appears in an update change list, though it does appear in a creation or deletion list, where only one side of the comparison exists.
|
||||
An update records one change object per differing field, so an unchanged field is absent. The field that holds the entity's own ID, such as `channel_id` or `role_id`, never appears in an update change list, though it does appear in a creation or deletion list, where only one side of the comparison exists.
|
||||
:::
|
||||
|
||||
### Change fields
|
||||
|
||||
@@ -44,7 +44,7 @@ The initial overwrite collection supplied when a channel is created. The stored
|
||||
|
||||
A larger decimal string returns 400 `INVALID_FORM_BODY` with the code `INTEGER_OUT_OF_INT64_RANGE`. A JSON number outside the safe integer range returns `INVALID_INTEGER_FORMAT`, and so does a string that is not all digits.
|
||||
|
||||
An undefined bit never fails the authority comparison described by [Create guild channel](#create-guild-channel). It is stored as supplied and echoed back by every later read of the channel.
|
||||
A bit that names no defined permission never fails the authority comparison described by [Create guild channel](#create-guild-channel). It is stored as supplied and echoed back by every later read of the channel.
|
||||
|
||||
A bit set in both masks resolves to an allow during [permission computation](/http-api/permissions/#permission-computation).
|
||||
|
||||
@@ -73,7 +73,7 @@ One entry of the bulk hierarchy update. Entries are applied in array order, each
|
||||
| preceding_sibling_id?<sup>3</sup> | ?snowflake | Sibling that sits directly before this channel, or null to place it first |
|
||||
| lock_permissions?<sup>4</sup> | boolean | Whether to copy the destination category's overwrites onto the moved channel (default false) |
|
||||
|
||||
<sup>1</sup> Read only when `preceding_sibling_id` is omitted. Sending `preceding_sibling_id` at all, including as null, makes it inert
|
||||
<sup>1</sup> Read only when `preceding_sibling_id` is omitted. When `preceding_sibling_id` is sent, including as null, Fluxer ignores `position`
|
||||
|
||||
<sup>2</sup> An omitted field keeps the channel's current parent. A category given any parent is refused with `CATEGORIES_CANNOT_HAVE_PARENTS`
|
||||
|
||||
@@ -191,7 +191,7 @@ Creates a guild channel and returns its [channel object](/http-api/channels/#cha
|
||||
|
||||
A value longer than 10000 characters is rejected with `STRING_LENGTH_INVALID` before normalisation runs. A parent that does not exist in this guild returns `INVALID_PARENT_CHANNEL`, and one that is not a category returns `PARENT_MUST_BE_CATEGORY`.
|
||||
|
||||
The guild holds at most the instance-configured `max_guild_channels` [limit](/http-api/instance/#limit-keys), defaulting to 500, and a parent category holds at most `max_channels_per_category`, defaulting to 50. Reaching the guild limit returns 400 `MAX_GUILD_CHANNELS` and reaching the category limit returns 400 `MAX_CATEGORY_CHANNELS`. Each message has the resolved limit. The new channel is created with no RTC region.
|
||||
The guild holds at most the instance-configured `max_guild_channels` [limit](/http-api/instance/#limit-keys), defaulting to 500, and a parent category holds at most `max_channels_per_category`, defaulting to 50. Reaching the guild limit returns 400 `MAX_GUILD_CHANNELS` and reaching the category limit returns 400 `MAX_CATEGORY_CHANNELS`. Each error message states the resolved limit. The new channel is created with no RTC region.
|
||||
|
||||
The new channel takes a position derived from its siblings. A category, and a channel created with no parent, is placed after the highest position in the guild. A voice channel created inside a category is placed after the last voice sibling, or after the last text or link sibling when the category holds no voice channel. A text or link channel is placed after the last text or link sibling, and a channel with no such sibling is placed directly after the category itself. The rest of the guild is not renumbered, so two channels can hold the same stored position until the next [hierarchy update](#modify-guild-channel-positions) renumbers them.
|
||||
|
||||
@@ -239,7 +239,7 @@ Applies a guild channel hierarchy update and returns 204 with an empty body. Req
|
||||
|
||||
Concurrent hierarchy updates to the same guild can return 423 [`GENERAL_ERROR`](/http-api/errors/) with a `Retry-After` header of two seconds.
|
||||
|
||||
An unknown channel, parent or sibling rejects the entire request. Other failures can leave earlier entries applied. Moving a category moves its children with it, and neither the category nor its children count as destination siblings.
|
||||
An unknown channel, parent or sibling rejects the entire request. Fluxer applies entries one at a time. When a later entry fails a check after that existence check, including the `lock_permissions` authority check, every earlier entry stays applied. Moving a category moves its children with it, and neither the category nor its children count as destination siblings.
|
||||
|
||||
After a move, positions run consecutively from 1 across the guild. Each category is followed by its children, with text and link channels before voice channels.
|
||||
|
||||
@@ -271,7 +271,7 @@ Channels at the guild root are exempt from this restriction.
|
||||
| 400<sup>1</sup><sup>2</sup> | [error response](/http-api/#error-response) | An entry is structurally invalid, the destination category is full, or the caller has no enrolled authenticator in an elevated-MFA guild |
|
||||
| 403 | [error response](/http-api/#error-response) | Caller lacks `MANAGE_CHANNELS`, is not a member of the guild, or set `lock_permissions` without sufficient authority in the moved channel, each returning `MISSING_PERMISSIONS` |
|
||||
| 404 | [error response](/http-api/#error-response) | Guild does not exist, returning `UNKNOWN_GUILD` |
|
||||
| 423 | [error response](/http-api/#error-response) | Another hierarchy update owns this guild, returning `GENERAL_ERROR` |
|
||||
| 423 | [error response](/http-api/#error-response) | Another hierarchy update to this guild is in progress, returning `GENERAL_ERROR` |
|
||||
|
||||
<sup>1</sup> A structural failure returns `INVALID_FORM_BODY` and names the field with `CHANNEL_NOT_FOUND`, `INVALID_CHANNEL_ID`, `INVALID_PARENT_CHANNEL`, `PARENT_MUST_BE_CATEGORY`, `CATEGORIES_CANNOT_HAVE_PARENTS`, `PRECEDING_CHANNEL_MUST_SHARE_PARENT`, `CANNOT_POSITION_CHANNEL_RELATIVE_TO_ITSELF`, or `VOICE_CHANNELS_CANNOT_BE_ABOVE_TEXT_CHANNELS`. A full destination category returns the top-level `MAX_CATEGORY_CHANNELS`
|
||||
|
||||
@@ -283,7 +283,7 @@ Each entry that changes the order emits [Channel Update Bulk](/gateway/events/#c
|
||||
|
||||
When `lock_permissions` copies the destination category's overwrites, re-read the moved channel to obtain them. The copy has no Gateway event or audit entry.
|
||||
|
||||
The caller must hold [MANAGE_ROLES](/http-api/permissions/) in the moved channel and each deny bit the copy removes or allow bit it adds. Insufficient authority returns 403 `MISSING_PERMISSIONS`, but the channel move remains applied.
|
||||
The copy requires [MANAGE_ROLES](/http-api/permissions/) in the moved channel. The caller must also hold every allow bit the copy adds and every deny bit the copy removes. Insufficient authority returns 403 `MISSING_PERMISSIONS`, but the channel move remains applied.
|
||||
|
||||
### Rate limit
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ Each successful item consumes one guild emoji slot and records an [`EMOJI_CREATE
|
||||
|
||||
Copies an existing emoji into the target guild and returns the new [guild emoji object](#guild-emoji-object) without `user`. Requires membership of the target guild and [CREATE_EXPRESSIONS](/http-api/permissions/) there. Emits a [Guild Emojis Update](/gateway/events/#guild-emojis-update) Gateway event in the target guild.
|
||||
|
||||
The name, animation state and image are copied unchanged. Membership of the source guild is not required, but it must have opted in with [CLONE_EMOJI_ENABLED](/http-api/guilds/#guild-features).
|
||||
The name, animation state and image are copied unchanged. Membership of the source guild is not required. The source guild must have [CLONE_EMOJI_ENABLED](/http-api/guilds/#guild-features).
|
||||
|
||||
[Get emoji metadata](/http-api/expressions/#get-emoji-metadata) reports whether a source permits cloning.
|
||||
|
||||
@@ -309,7 +309,7 @@ An emoji that does not belong to the guild in the path returns 404 `UNKNOWN_EMOJ
|
||||
|
||||
| Status | Body | Condition |
|
||||
| --- | --- | --- |
|
||||
| 200 | [guild emoji](#guild-emoji-object) object | Name was processed |
|
||||
| 200 | [guild emoji](#guild-emoji-object) object | Name was set, including a name equal to the current one |
|
||||
| 400<sup>1</sup> | [error response](/http-api/#error-response) | Path parameter or name is invalid |
|
||||
| 403<sup>2</sup> | [error response](/http-api/#error-response) | Name is blocked, the guild is unavailable, or the caller is neither the uploader with `CREATE_EXPRESSIONS` nor a member holding `MANAGE_EXPRESSIONS` |
|
||||
| 404 | [error response](/http-api/#error-response) | Emoji does not exist in that guild and the request returns `UNKNOWN_EMOJI` |
|
||||
|
||||
@@ -85,7 +85,7 @@ The result has no `avatar`, `banner`, `accent_color`, `mute`, `deaf`, `communica
|
||||
|
||||
## Guild member search supplemental object
|
||||
|
||||
A guild member search supplemental object has the join source the [guild member object](/http-api/guild-members/#guild-member-object) never exposes. Only a caller that can already manage the guild reads it.
|
||||
A guild member search supplemental object has the join source the [guild member object](/http-api/guild-members/#guild-member-object) never exposes. Only a caller holding [MANAGE_GUILD](/http-api/permissions/) receives its values.
|
||||
|
||||
### Structure
|
||||
|
||||
@@ -210,7 +210,7 @@ An instance using Meilisearch returns an empty page beyond 10000 results. Instan
|
||||
|
||||
<sup>1</sup> The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild, `CONTENT_BLOCKED` for a blocked body string, and `MISSING_PERMISSIONS` otherwise
|
||||
|
||||
When results are being prepared, the response is 200 with an empty page and `indexing` set to true. Retry later. Search unavailability can also produce an empty page with `indexing` false, rather than `FEATURE_TEMPORARILY_DISABLED`.
|
||||
When Fluxer is building the guild member index, the response is 200 with an empty page and `indexing` set to true. Retry later. This route has no `FEATURE_TEMPORARILY_DISABLED` response. When the search backend is unavailable, the route returns 200 with an empty page and `indexing` false.
|
||||
|
||||
:::note[`indexing` false does not mean zero matches]
|
||||
An empty page with `indexing` false can mean no matches or that search is unavailable.
|
||||
@@ -218,7 +218,7 @@ An empty page with `indexing` false can mean no matches or that search is unavai
|
||||
|
||||
### Side effects
|
||||
|
||||
The first search can begin preparing the guild's results. It does not change memberships.
|
||||
A search in a guild whose member index needs building queues an indexing job and returns `indexing` true. It does not change memberships.
|
||||
|
||||
### Rate limit
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import RouteHeader from '@/components/RouteHeader.astro';
|
||||
|
||||
A guild member is an account that has joined a guild. The membership has a nickname, avatar, role set, and moderation state that apply in that guild alone. Indexed member queries live on [Guild member search](/http-api/guild-member-search/), and bans on [Guild moderation](/http-api/guild-moderation/).
|
||||
|
||||
An unknown guild returns 404 `UNKNOWN_GUILD`, a non-member receives 403 `MISSING_PERMISSIONS`, and a guild marked unavailable returns 403 `MISSING_ACCESS`. [Transfer guild ownership](#transfer-guild-ownership) can also return 403 `ACCESS_DENIED` when the guild cannot be accessed.
|
||||
An unknown guild returns 404 `UNKNOWN_GUILD`, a non-member receives 403 `MISSING_PERMISSIONS`, and a guild marked unavailable returns 403 `MISSING_ACCESS`. [Transfer guild ownership](#transfer-guild-ownership) also returns 403 `ACCESS_DENIED` when the guild exists in storage but the Gateway reports it as not found.
|
||||
|
||||
The modify operations return the resulting membership, [Transfer guild ownership](#transfer-guild-ownership) returns the updated guild, and every other mutation returns 204 with an empty body.
|
||||
|
||||
@@ -49,7 +49,7 @@ Every field except `user` describes state that belongs to the membership.
|
||||
|
||||
<sup>6</sup> Omitted while the stored value is 0, so absence means no flag is set and, for `mention_flags`, that the account-level preference applies
|
||||
|
||||
Guild avatars and banners can be hidden after the account loses its premium entitlement.
|
||||
Fluxer marks a membership premium sanitised after its account loses premium, when the membership has a guild avatar, banner, bio, or accent colour. A premium sanitised membership reports `avatar` and `banner` as null.
|
||||
|
||||
A client compares `communication_disabled_until` against the current time, because a non-null value can name a moment that has already passed. A [communication timeout](/http-api/permissions/#communication-timeout) does not reduce the member's computed permission mask.
|
||||
|
||||
@@ -76,7 +76,7 @@ A client compares `communication_disabled_until` against the current time, becau
|
||||
|
||||
## Guild member profile flags
|
||||
|
||||
A guild profile asset has these states. A membership with no flag and no stored hash inherits the account-level asset. A stored hash sets a guild-specific asset. The flag below blocks inheritance and renders the default.
|
||||
A guild profile asset has these states. A membership with no flag and no stored hash inherits the account-level asset. A stored hash sets a guild-specific asset. Each flag below blocks inheritance, so the client shows the default asset.
|
||||
|
||||
| Value | Name | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -127,7 +127,7 @@ The request body shared by [Modify current guild member](#modify-current-guild-m
|
||||
|
||||
A `roles` entry that does not resolve to an existing role of the guild is dropped from the replacement, so a request naming only unknown roles clears the member's role set.
|
||||
|
||||
An `avatar` or `banner` base64 payload longer than 13981016 characters returns `BASE64_LENGTH_INVALID`, and a malformed one returns `INVALID_BASE64_FORMAT`. Fluxer then checks the decoded bytes against the instance-configured `avatar_max_size` [limit](/http-api/instance/#limit-keys), whose stock value is 10485760. The same limit applies to both fields. The decoded image must also pass the format allowlist and the animation rules of the asset policy for the field it sets. Pixel dimensions are not enforced. A value that is too large returns `IMAGE_SIZE_EXCEEDS_LIMIT`, and one whose format or animation is not allowed returns `INVALID_IMAGE_FORMAT`.
|
||||
An `avatar` or `banner` base64 payload longer than 13981016 characters returns `BASE64_LENGTH_INVALID`, and a malformed one returns `INVALID_BASE64_FORMAT`. Fluxer then checks the decoded bytes against the instance-configured `avatar_max_size` [limit](/http-api/instance/#limit-keys), whose stock value is 10485760. The same limit applies to both fields. The decoded image must also be in a format the field accepts, and an animated AVIF is rejected. Pixel dimensions are not enforced. A value that is too large returns `IMAGE_SIZE_EXCEEDS_LIMIT`, and one whose format or animation is not allowed returns `INVALID_IMAGE_FORMAT`.
|
||||
|
||||
Null and any `communication_disabled_until` that is not in the future both clear the timeout. A time more than 365.25 days ahead returns `TIMEOUT_CANNOT_EXCEED_365_DAYS`, and a value that passes the schema but is not a real instant returns `INVALID_TIMEOUT_VALUE`. `timeout_reason` has no effect unless `communication_disabled_until` is also supplied.
|
||||
|
||||
@@ -318,7 +318,7 @@ A caller addressing their own user ID here follows the self-targeted rules of [M
|
||||
:::
|
||||
|
||||
:::caution[A blocklisted `bio` or `pronouns` returns 403]
|
||||
The request fails with `CONTENT_BLOCKED` even though the field is never written.
|
||||
A request that supplies a blocked `bio` or `pronouns` for another member fails with `CONTENT_BLOCKED`, even though Fluxer never writes those fields for another member.
|
||||
:::
|
||||
|
||||
### Path parameters
|
||||
@@ -350,7 +350,7 @@ The body is a [guild member update object](#guild-member-update-object).
|
||||
|
||||
### Side effects
|
||||
|
||||
The operation has the same effects as [Modify current guild member](#modify-current-guild-member). Applying a non-empty role set also makes a temporary membership permanent.
|
||||
The operation has the same effects as [Modify current guild member](#modify-current-guild-member). Applying a non-empty role set also makes a temporary membership permanent. A membership is temporary when the member joined through an [invite](/http-api/invites/#invite-object) whose `temporary` field is true.
|
||||
|
||||
### Rate limit
|
||||
|
||||
|
||||
@@ -45,12 +45,12 @@ A guild ban object names the banned account, the moderator, the reason, and any
|
||||
```
|
||||
|
||||
:::note[Fluxer never returns the ban address or email]
|
||||
A ban can also prevent accounts using the same IP address or email from joining. These values are not exposed in the ban object.
|
||||
A ban also stores the email address and the last active IP address of the banned account. Fluxer rejects an invite join by any account that matches either value. These values are not exposed in the ban object.
|
||||
:::
|
||||
|
||||
An [invite](/http-api/invites/) rejected for an IP match returns 403 `USER_IP_BANNED_FROM_GUILD`. An account or email match returns 403 `USER_BANNED_FROM_GUILD`. Address exemptions can allow a join from a shared network. Ban checks differ for administrator actions, bot installation and automatic guild joins.
|
||||
An [invite](/http-api/invites/) rejected for an IP match returns 403 `USER_IP_BANNED_FROM_GUILD`. An account or email match returns 403 `USER_BANNED_FROM_GUILD`. Fluxer skips the IP match when the joining account's address is on the operator's IP ban exemption list, or when IP lookup classifies that address as carrier-grade NAT or shared access. Adding a member through the Admin API, installing a bot through OAuth2, and the automatic join into the visionaries guild after a Stripe purchase skip every ban check. The automatic join into the single community checks the account and IP matches and skips the email match.
|
||||
|
||||
[Remove guild ban](#remove-guild-ban) releases both blocks. Permanently deleting the banned account deletes every guild ban it holds, and that releases both blocks in every guild at once.
|
||||
[Remove guild ban](#remove-guild-ban) releases the IP address block and the email block. Permanently deleting the banned account deletes every guild ban it holds, which releases both of those blocks in every guild at once.
|
||||
|
||||
## List guild bans
|
||||
|
||||
@@ -95,7 +95,7 @@ Creates a guild ban, or replaces an existing one, and returns 204 with an empty
|
||||
|
||||
- The caller cannot ban themselves, and Fluxer reports a self-target as 404 `UNKNOWN_MEMBER`.
|
||||
- Banning a target who is a member also requires role hierarchy authority over that member. The guild owner holds that authority over everyone, and no other caller holds it over the owner.
|
||||
- A target who is not a member can still be banned, and the ban pre-empts a future join.
|
||||
- A target who is not a member can still be banned, and Fluxer refuses a later join by that account.
|
||||
|
||||
A blocked `reason` returns 403 `CONTENT_BLOCKED`.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Every route names a guild in its path. A guild that has [UNAVAILABLE_FOR_EVERYON
|
||||
|
||||
Names, descriptions, tags and uploaded images must pass the instance's content policy. Blocked content returns 403 `CONTENT_BLOCKED`.
|
||||
|
||||
A guild that does not exist returns 404 `UNKNOWN_GUILD`. A caller who is not a current member of an existing guild returns 403 `MISSING_PERMISSIONS`, so guild existence is distinguishable from guild membership. [Modify guild sticker](#modify-guild-sticker) answers 404 `UNKNOWN_STICKER` for such a guild.
|
||||
A guild that does not exist returns 404 `UNKNOWN_GUILD`. A caller who is not a current member of an existing guild returns 403 `MISSING_PERMISSIONS`, so guild existence is distinguishable from guild membership. [Modify guild sticker](#modify-guild-sticker) returns 404 `UNKNOWN_STICKER` for a guild that does not exist.
|
||||
|
||||
There is no single-sticker read scoped to a guild. [List guild stickers](#list-guild-stickers) returns the whole collection in one response.
|
||||
|
||||
@@ -132,7 +132,7 @@ One rejected item from a bulk create call, named and explained in display text.
|
||||
|
||||
<sup>2</sup> Rendered in the locale of the authenticated account, so its value changes with the caller's locale
|
||||
|
||||
There is no machine-readable code, so a client that needs to branch on the reason retries the item on its own.
|
||||
There is no machine-readable code, so a client that needs to branch on the reason submits that item again through [Create guild sticker](#create-guild-sticker), which returns an error code.
|
||||
|
||||
## List guild stickers
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ A guild is a community with its own channels, roles, members, and configuration.
|
||||
|
||||
[List current user guilds](#list-current-user-guilds) and [Get guild](#get-guild) declare the `guilds` [OAuth2 scope](/http-api/oauth2/#oauth2-scopes), and a bearer credential without that scope receives 403 `MISSING_OAUTH_SCOPE`. Every other route rejects a bearer credential with 403 `ACCESS_DENIED`.
|
||||
|
||||
A guild that does not exist returns 404 `UNKNOWN_GUILD`. A non-member receives 403 `MISSING_PERMISSIONS`. A guild that exists but cannot be accessed can return 403 `ACCESS_DENIED`.
|
||||
A guild that does not exist returns 404 `UNKNOWN_GUILD`. A non-member receives 403 `MISSING_PERMISSIONS`. A guild that exists in storage but that the Gateway reports as not found returns 403 `ACCESS_DENIED`.
|
||||
|
||||
An [unavailable guild](#guild-features) can still appear in [List current user guilds](#list-current-user-guilds). Members can still [leave](#leave-guild) or [delete their own messages](#bulk-delete-current-users-guild-messages).
|
||||
|
||||
@@ -93,7 +93,7 @@ A guild object contains the guild's configuration. The operation that returns it
|
||||
|
||||
<sup>14</sup> Only [Get guild](#get-guild) populates these fields
|
||||
|
||||
<sup>15</sup> Only [List current user guilds](#list-current-user-guilds) populates these fields, and only when `with_counts` is true. A guild whose counts are not currently held reports 0 for both
|
||||
<sup>15</sup> Only [List current user guilds](#list-current-user-guilds) populates these fields, and only when `with_counts` is true. A guild for which the Gateway holds no cached counts reports 0 for both
|
||||
|
||||
:::note[An absent field means the operation omitted it]
|
||||
`roles`, `emojis`, `stickers`, `channels`, and every count field arrive only from the operations named in the footnotes above. A guild returned without `roles` can still hold roles.
|
||||
@@ -251,7 +251,7 @@ A creation template describes the roles and channels that [Create guild](#create
|
||||
|
||||
<sup>1</sup> A duplicate identifier in the same template rejects creation with 400 `GUILD_TEMPLATE_INVALID`
|
||||
|
||||
<sup>2</sup> The value 0 creates a text channel, 2 a voice channel, and 4 a category, and for an import from the other platform 5 creates a text channel and 13 a voice channel. Fluxer skips every other value, so the channel is not created
|
||||
<sup>2</sup> The value 0 creates a text channel, 2 a voice channel, and 4 a category. The value 5, the announcement channel type of another platform, creates a text channel. The value 13, the stage channel type of another platform, creates a voice channel. Fluxer skips every other value, so the channel is not created
|
||||
|
||||
<sup>3</sup> The identifier is applied only when it resolves to a category in the same template, and every other value leaves the channel at the guild root
|
||||
|
||||
@@ -390,7 +390,7 @@ Each value in the guild's `features` array is a capability or availability flag.
|
||||
| INVITE_SPLASH | Guild can use invite splash assets |
|
||||
| INVITES_DISABLED<sup>1</sup> | Guild invite use is disabled |
|
||||
| RAID_DETECTED | Raid detection is active and invites are restricted |
|
||||
| TEXT_CHANNEL_FLEXIBLE_NAMES<sup>1</sup> | Text channel names accept the flexible naming policy |
|
||||
| TEXT_CHANNEL_FLEXIBLE_NAMES<sup>1</sup> | Text channel names keep uppercase letters, spaces, and punctuation |
|
||||
| HIDE_OWNER_CROWN<sup>1</sup> | Guild owner crown is hidden |
|
||||
| MORE_EMOJI<sup>2</sup> | Legacy increased emoji slot allowance |
|
||||
| MORE_STICKERS<sup>2</sup> | Legacy increased sticker slot allowance |
|
||||
@@ -401,11 +401,11 @@ Each value in the guild's `features` array is a capability or availability flag.
|
||||
| DISCOVERABLE | Guild is present in public discovery |
|
||||
| PARTNERED | Guild has partnered status |
|
||||
| VERIFIED | Guild has verified status |
|
||||
| VIP_VOICE | Guild has VIP voice capability |
|
||||
| VIP_VOICE | Guild can use voice regions that are restricted to VIP guilds |
|
||||
| VOICE_E2EE | Guild voice channels support end-to-end encryption |
|
||||
| UNAVAILABLE_FOR_EVERYONE<sup>4</sup> | Guild is unavailable to every account |
|
||||
| UNAVAILABLE_FOR_EVERYONE_BUT_STAFF<sup>4</sup> | Guild is unavailable to every account without the instance staff flag |
|
||||
| UNAVAILABLE_HIDDEN | Guild is hidden while it is forced unavailable |
|
||||
| UNAVAILABLE_HIDDEN | While the guild is unavailable, the Gateway sends its unavailable guild entry with `unavailable_hidden: true` |
|
||||
| VISIONARY | Guild has visionary status |
|
||||
| LARGE_GUILD_OVERRIDE<sup>2</sup> | Guild is marked as a large guild |
|
||||
| VERY_LARGE_GUILD<sup>5</sup> | Guild member capacity is raised |
|
||||
@@ -414,7 +414,7 @@ Each value in the guild's `features` array is a capability or availability flag.
|
||||
|
||||
<sup>2</sup> The feature changes no HTTP API behaviour. An instance can name it in a filter of the ordered [limit configuration](/http-api/instance/#limit-keys), and the stock configuration names none of them
|
||||
|
||||
<sup>3</sup> The expression operations raise the slot ceiling directly, outside the instance limit configuration
|
||||
<sup>3</sup> Emoji and sticker creation use a fixed slot ceiling of 999999 and ignore the instance limit configuration
|
||||
|
||||
<sup>4</sup> Guild and channel routes return 403 `MISSING_ACCESS`. `UNAVAILABLE_FOR_EVERYONE` includes the guild owner. `UNAVAILABLE_FOR_EVERYONE_BUT_STAFF` exempts accounts with the instance staff flag
|
||||
|
||||
@@ -494,7 +494,7 @@ Creates a guild owned by the caller. Requires a user session credential. Returns
|
||||
- An unclaimed account is rejected with 400 `UNCLAIMED_ACCOUNT_CANNOT_CREATE_GUILDS`.
|
||||
- An account without a verified email address is rejected with 403 `GUILD_CREATION_EMAIL_VERIFICATION_REQUIRED`.
|
||||
- A caller already at the configured guild limit is rejected with 400 `MAX_GUILDS`.
|
||||
- While the instance's single community policy is active, every caller is rejected with 400 `SINGLE_COMMUNITY_CANNOT_CREATE_GUILDS`.
|
||||
- While `single_community_enabled` is true in the [instance policy](/admin-api/instance/#instance-policy-object), every caller is rejected with 400 `SINGLE_COMMUNITY_CANNOT_CREATE_GUILDS`.
|
||||
|
||||
### JSON body
|
||||
|
||||
@@ -601,7 +601,7 @@ The response has no `permissions` field. Read [List current user guilds](#list-c
|
||||
| 403<sup>1</sup> | [error response](/http-api/#error-response) | Guild is unavailable, the bearer credential lacks the `guilds` scope, or the caller is not a member |
|
||||
| 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` |
|
||||
|
||||
<sup>1</sup> The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild, `MISSING_OAUTH_SCOPE` for a missing scope, `MISSING_PERMISSIONS` for a non-member, and `ACCESS_DENIED` when the guild otherwise cannot be accessed
|
||||
<sup>1</sup> The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild, `MISSING_OAUTH_SCOPE` for a missing scope, `MISSING_PERMISSIONS` for a non-member, and `ACCESS_DENIED` when the guild exists in storage but the Gateway reports it as not found
|
||||
|
||||
### Rate limit
|
||||
|
||||
@@ -715,9 +715,9 @@ Send the current array with the intended changes applied. A feature without the
|
||||
|
||||
Every successful request emits [Guild Update](/gateway/events/#guild-update) to every session that can see the guild, and a request that writes no field still emits it.
|
||||
|
||||
Only a change to an audited field records a [`GUILD_UPDATE`](/http-api/guild-audit-logs/#audit-actions) audit entry with the previous and new values. That entry emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) to sessions that can read the audit log.
|
||||
A request that changes the stored value of at least one body field records a [`GUILD_UPDATE`](/http-api/guild-audit-logs/#audit-actions) audit entry with the previous and new values. That entry emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) to sessions that can read the audit log.
|
||||
|
||||
Removing `TEXT_CHANNEL_FLEXIBLE_NAMES` renames every guild text channel whose stored name does not satisfy the strict naming policy. When at least one channel is renamed, the removal emits one [Channel Update Bulk](/gateway/events/#channel-update-bulk) Dispatch with every channel in the guild.
|
||||
Removing `TEXT_CHANNEL_FLEXIBLE_NAMES` renames every guild text channel whose stored name changes when Fluxer trims it, lowercases it, replaces each whitespace run with a hyphen, and removes disallowed punctuation. When at least one channel is renamed, the removal emits one [Channel Update Bulk](/gateway/events/#channel-update-bulk) Dispatch with every channel in the guild.
|
||||
|
||||
Replacing an image removes the previous asset. A failed replacement leaves the previous image unchanged.
|
||||
|
||||
@@ -733,7 +733,7 @@ Permanently deletes the guild and returns 204 with an empty body. Requires the g
|
||||
|
||||
Fluxer refuses a non-owner with 403 `MISSING_PERMISSIONS`. A bot can never own a guild, so a bot credential never satisfies the requirement.
|
||||
|
||||
A guild protected by an active single community policy cannot be deleted and returns 400 `SINGLE_COMMUNITY_CANNOT_DELETE`.
|
||||
While `single_community_enabled` is true in the [instance policy](/admin-api/instance/#instance-policy-object), the guild named by `single_community_guild_id` cannot be deleted and returns 400 `SINGLE_COMMUNITY_CANNOT_DELETE`.
|
||||
|
||||
### Path parameters
|
||||
|
||||
@@ -772,7 +772,7 @@ Deletion removes the guild, memberships, roles, channels, messages, attachments,
|
||||
|
||||
### Side effects
|
||||
|
||||
Every member receives [Guild Delete](/gateway/events/#guild-delete). Guild settings are removed. Non-bot members also receive [User Settings Update](/gateway/events/#user-settings-update) with the guild removed from their folder layout.
|
||||
Every member receives [Guild Delete](/gateway/events/#guild-delete). Fluxer deletes every member's [user guild settings](/http-api/users/settings/#user-guild-settings-object) for the guild. Non-bot members also receive [User Settings Update](/gateway/events/#user-settings-update) with the guild removed from their folder layout.
|
||||
|
||||
No audit log entry is recorded, because the audit log is destroyed with the guild. An `X-Audit-Log-Reason` header on this request is read and discarded.
|
||||
|
||||
@@ -786,7 +786,7 @@ No audit log entry is recorded, because the audit log is destroyed with the guil
|
||||
|
||||
Removes the authenticated account's membership and returns 204 with an empty body. Requires a current membership. Emits a [Guild Member Remove](/gateway/events/#guild-member-remove) Gateway event to the remaining guild sessions and a [Guild Delete](/gateway/events/#guild-delete) Gateway event to the leaving account's own sessions.
|
||||
|
||||
A caller with no current membership receives 404 `UNKNOWN_MEMBER` whether or not the guild exists. The guild owner cannot leave and receives 400 `INVALID_FORM_BODY` with the field code `CANNOT_LEAVE_GUILD_AS_OWNER`. A guild protected by an active single community policy cannot be left and returns 400 `SINGLE_COMMUNITY_CANNOT_LEAVE`. Setting `delete_messages` requires sudo mode, which a bot credential satisfies implicitly.
|
||||
A caller with no current membership receives 404 `UNKNOWN_MEMBER` whether or not the guild exists. The guild owner cannot leave and receives 400 `INVALID_FORM_BODY` with the field code `CANNOT_LEAVE_GUILD_AS_OWNER`. While `single_community_enabled` is true in the [instance policy](/admin-api/instance/#instance-policy-object), the guild named by `single_community_guild_id` cannot be left and returns 400 `SINGLE_COMMUNITY_CANNOT_LEAVE`. Setting `delete_messages` requires sudo mode, which a bot credential satisfies implicitly.
|
||||
|
||||
### Path parameters
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ An empty string becomes `null` at any depth, and an empty nested object becomes
|
||||
|
||||
This normalisation applies to JSON and form bodies, query strings, path parameters, request headers, and cookies.
|
||||
|
||||
A nested object containing only `null` values also becomes `null`. The root object is preserved. An empty body is treated as `{}` and validated for required fields. Malformed JSON returns 400 `INVALID_FORM_BODY` with a validation error at path `body` and code `INVALID_FORMAT`.
|
||||
A nested object containing only `null` values also becomes `null`. The root object never becomes `null`, even when it is empty or has only `null` values. An empty body is treated as `{}` and validated for required fields. Malformed JSON returns 400 `INVALID_FORM_BODY` with a validation error at path `body` and code `INVALID_FORMAT`.
|
||||
|
||||
:::caution[Message operations preserve empty values]
|
||||
[Create message](/http-api/messages/#create-message), [Modify message](/http-api/messages/#modify-message), and [Execute webhook](/http-api/webhooks/#execute-webhook) do not apply this normalisation.
|
||||
@@ -162,7 +162,7 @@ An operation that sets its own `Cache-Control` keeps that value. A response whos
|
||||
|
||||
## Rate limits
|
||||
|
||||
Every route consumes its own rate limit bucket and is also evaluated against one global bucket unless that bucket is exempt. A denial returns 429 `RATE_LIMITED`. [Rate limits](/topics/rate-limits/) defines the bucket scoping rules, the global allowance, the 429 body, the scope registry, and the complete `X-RateLimit-*` header contract.
|
||||
Every route consumes its own rate limit bucket. A route that is not exempt from the global bucket also counts against one global bucket. A denial returns 429 `RATE_LIMITED`. [Rate limits](/topics/rate-limits/) defines the bucket scoping rules, the global allowance, the 429 body, the scope registry, and the complete `X-RateLimit-*` header contract.
|
||||
|
||||
:::note[These 429 responses have no `X-RateLimit-*` header]
|
||||
A 429 `RESOURCE_LOCKED` response has `Retry-After: 1`, and a 429 `IP_AUTHORIZATION_RESEND_COOLDOWN` response has the remaining cooldown in whole seconds. A client that reads the bucket headers branches on `code`.
|
||||
@@ -180,7 +180,7 @@ The CORS response policy is an allow-list of exactly two origins, the deployment
|
||||
|
||||
The paths below are readable from any origin. `/v1/webhooks/{webhook_id}/{token}` and `/v1/webhooks/{webhook_id}/{token}/messages/{message_id}` have a second cross-origin policy that allows any origin. Four of the methods registered on them refuse the first-party web client outright, and that refusal is defined by [Origin refusal](/http-api/webhooks/#origin-refusal).
|
||||
|
||||
[Get instance discovery](/http-api/instance/#get-instance-discovery) on `/.well-known/fluxer`, [Get OpenAPI document](/http-api/instance/#get-openapi-document) on `/v1/openapi.json`, and [Get client geolocation](/http-api/instance/#get-client-geolocation) on `/v1/ip` set `Access-Control-Allow-Origin: *` in the operation itself. The wildcard stands for any origin outside the allow-list, and for an allowed origin the policy replaces it with that exact origin and sends `Vary: Origin`.
|
||||
[Get instance discovery](/http-api/instance/#get-instance-discovery) on `/.well-known/fluxer`, [Get OpenAPI document](/http-api/instance/#get-openapi-document) on `/v1/openapi.json`, and [Get client geolocation](/http-api/instance/#get-client-geolocation) on `/v1/ip` set `Access-Control-Allow-Origin: *` in the operation itself. A request whose `Origin` is absent or outside the allow-list receives `*`. For an allowed origin, the policy replaces `*` with that exact origin and sends `Vary: Origin`.
|
||||
|
||||
`Access-Control-Expose-Headers` is the value `X-Fluxer-Version, ETag`. Every other Fluxer response header, the rate limit headers and `X-Request-ID` included, is hidden from cross-origin script. `Access-Control-Allow-Headers` is `Content-Type, Authorization, X-Requested-With, Accept-Language, X-Request-ID, If-None-Match`, so a cross-origin client revalidates an [ETag](/http-api/experiments/#get-experiment-assignments) it was served.
|
||||
|
||||
@@ -255,7 +255,7 @@ Each entry identifies one failed input field. A 400 response whose top-level cod
|
||||
}
|
||||
```
|
||||
|
||||
Fluxer produces at most one entry for each distinct pair of `path` and `code`, so a field that fails several equivalent constraints appears once.
|
||||
Fluxer produces at most one entry for each distinct pair of `path` and `code`, so a field that fails several constraints with the same `code` appears once.
|
||||
|
||||
## Resource pages
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ Each value is an absolute URL supplied by the operator. A value can be a bare or
|
||||
|
||||
<sup>2</sup> The value is the configured Gateway endpoint and its scheme is `ws` or `wss` as the operator configured it
|
||||
|
||||
<sup>3</sup> A deployment that configures a separate static asset domain derives that value over `https` on the configured domain and without the port the other endpoints have
|
||||
<sup>3</sup> When a deployment configures a separate static asset domain, the value is `https://` followed by that domain, with no port
|
||||
|
||||
Every value is the exact origin the deployment advertises, including its scheme and any explicit port, and a deliberately plain HTTP deployment publishes `http` and `ws` values here.
|
||||
|
||||
@@ -193,10 +193,10 @@ Whether this deployment runs as one community, and whether direct messages exist
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| single_community | boolean | Whether this deployment runs as one community that every account joins |
|
||||
| single_community_guild_id<sup>1</sup> | ?snowflake | The stock community guild ID, or null |
|
||||
| single_community_guild_id<sup>1</sup> | ?snowflake | The ID of the guild every account joins in single-community mode, or null |
|
||||
| direct_messages_disabled | boolean | Whether direct messages and friend requests are disabled for the whole deployment |
|
||||
|
||||
<sup>1</sup> The identifier is published only while `single_community` is true and a stock community has been chosen
|
||||
<sup>1</sup> The identifier is published only while `single_community` is true and the deployment has designated a community guild
|
||||
|
||||
## Service availability object
|
||||
|
||||
@@ -258,10 +258,10 @@ The document lets a client present the correct bounds before it attempts an oper
|
||||
:::
|
||||
|
||||
:::caution[An unauthenticated reader resolves only the unfiltered outcome]
|
||||
The document publishes neither the requesting account's traits nor any guild feature set. A client supplies that context from its own authenticated state.
|
||||
The document publishes neither the requesting account's traits nor any guild feature set. A client supplies the account's traits and the guild's feature names itself, from data it received while authenticated.
|
||||
:::
|
||||
|
||||
Nothing validates a rule's `traits` filter against `traitDefinitions`, so a rule can filter on a name that collection omits. A self-hosted deployment whose premium mode grants every account the stock limits publishes an empty collection and has no rule that filters on `premium`.
|
||||
Nothing validates a rule's `traits` filter against `traitDefinitions`, so a rule can filter on a name that collection omits. A self-hosted deployment whose [premium mode](/admin-api/instance/#premium-modes) is `everyone` publishes an empty collection and has no rule that filters on `premium`.
|
||||
|
||||
## Limit rule object
|
||||
|
||||
@@ -353,7 +353,7 @@ Each key names one limit. A key whose name begins with `feature_` is a feature g
|
||||
| max_webhooks_per_guild | Maximum webhooks per guild, in guild scope |
|
||||
| sticker_max_size | Maximum file size for sticker uploads in bytes, in guild scope |
|
||||
|
||||
<sup>1</sup> Emoji limits are enforced as one shared total against `max_guild_emojis`, and the aliases exist only so an older client reads a plausible value
|
||||
<sup>1</sup> Emoji limits are enforced as one shared total against `max_guild_emojis`, and the aliases exist only for older clients. The default of each alias equals the `max_guild_emojis` default
|
||||
|
||||
<sup>2</sup> Sticker limits are enforced as one shared total against `max_guild_stickers`
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user