Compare commits

...
344 changed files with 97753 additions and 9216 deletions
Generated
+14 -13
View File
@@ -266,9 +266,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.16.3"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"zeroize",
@@ -276,14 +276,15 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.40.0"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
@@ -1622,7 +1623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -3988,14 +3989,14 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
name = "rustls"
version = "0.23.40"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"log",
@@ -4047,7 +4048,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -4058,9 +4059,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
@@ -4671,7 +4672,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -5539,7 +5540,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
+301
View File
@@ -38,6 +38,8 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
let mut spec: openapiv3::OpenAPI =
serde_json::from_str(&json_str).expect("failed to parse openapi-admin.json");
adapt_progenitor_throttled_errors(&mut spec);
relax_guild_audit_log_schemas(&mut spec);
relax_progenitor_schema_strictness(&mut spec);
let mut settings = progenitor::GenerationSettings::new();
settings.with_interface(progenitor::InterfaceStyle::Positional);
@@ -141,6 +143,305 @@ fn adapt_progenitor_throttled_errors(spec: &mut openapiv3::OpenAPI) {
}
}
fn relax_guild_audit_log_schemas(spec: &mut openapiv3::OpenAPI) {
let components = spec.components.as_mut().expect("missing API components");
let entry = object_schema_mut(components, "GuildAuditLogEntryResponse");
entry.additional_properties = None;
let openapiv3::ReferenceOr::Item(options) = entry
.properties
.get_mut("options")
.expect("GuildAuditLogEntryResponse has no options property")
else {
panic!("GuildAuditLogEntryResponse options must be an inline schema");
};
let openapiv3::SchemaKind::Type(openapiv3::Type::Object(options)) = &mut options.schema_kind
else {
panic!("GuildAuditLogEntryResponse options must be an object schema");
};
options.additional_properties = None;
let change = object_schema_mut(components, "AuditLogChangeSchema");
change.additional_properties = None;
for property in ["old_value", "new_value"] {
change.properties.insert(
property.to_string(),
openapiv3::ReferenceOr::Item(Box::new(openapiv3::Schema {
schema_data: openapiv3::SchemaData::default(),
schema_kind: openapiv3::SchemaKind::Any(openapiv3::AnySchema::default()),
})),
);
}
}
fn object_schema_mut<'a>(
components: &'a mut openapiv3::Components,
name: &str,
) -> &'a mut openapiv3::ObjectType {
let Some(openapiv3::ReferenceOr::Item(schema)) = components.schemas.get_mut(name) else {
panic!("missing inline {name} schema");
};
let openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) = &mut schema.schema_kind
else {
panic!("{name} must be an object schema");
};
object
}
const MAX_SCHEMA_REFERENCE_DEPTH: usize = 32;
fn relax_progenitor_schema_strictness(spec: &mut openapiv3::OpenAPI) {
let registry = spec.components.clone().unwrap_or_default();
if let Some(components) = spec.components.as_mut() {
for schema in components.schemas.values_mut() {
relax_schema_reference(schema, &registry);
}
for response in components.responses.values_mut() {
if let openapiv3::ReferenceOr::Item(response) = response {
relax_response(response, &registry);
}
}
for parameter in components.parameters.values_mut() {
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
relax_parameter(parameter, &registry);
}
}
for request_body in components.request_bodies.values_mut() {
if let openapiv3::ReferenceOr::Item(request_body) = request_body {
relax_content(&mut request_body.content, &registry);
}
}
for header in components.headers.values_mut() {
if let openapiv3::ReferenceOr::Item(header) = header {
relax_parameter_format(&mut header.format, &registry);
}
}
}
for path in spec.paths.paths.values_mut() {
let openapiv3::ReferenceOr::Item(path) = path else {
continue;
};
for parameter in &mut path.parameters {
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
relax_parameter(parameter, &registry);
}
}
for operation in [
&mut path.get,
&mut path.put,
&mut path.post,
&mut path.delete,
&mut path.options,
&mut path.head,
&mut path.patch,
&mut path.trace,
]
.into_iter()
.flatten()
{
for parameter in &mut operation.parameters {
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
relax_parameter(parameter, &registry);
}
}
if let Some(openapiv3::ReferenceOr::Item(request_body)) =
operation.request_body.as_mut()
{
relax_content(&mut request_body.content, &registry);
}
for response in operation
.responses
.responses
.values_mut()
.chain(operation.responses.default.iter_mut())
{
if let openapiv3::ReferenceOr::Item(response) = response {
relax_response(response, &registry);
}
}
}
}
}
fn relax_response(response: &mut openapiv3::Response, registry: &openapiv3::Components) {
relax_content(&mut response.content, registry);
for header in response.headers.values_mut() {
if let openapiv3::ReferenceOr::Item(header) = header {
relax_parameter_format(&mut header.format, registry);
}
}
}
fn relax_content(content: &mut openapiv3::Content, registry: &openapiv3::Components) {
for media_type in content.values_mut() {
if let Some(schema) = media_type.schema.as_mut() {
relax_schema_reference(schema, registry);
}
}
}
fn relax_parameter(parameter: &mut openapiv3::Parameter, registry: &openapiv3::Components) {
let format = match parameter {
openapiv3::Parameter::Query { parameter_data, .. }
| openapiv3::Parameter::Header { parameter_data, .. }
| openapiv3::Parameter::Path { parameter_data, .. }
| openapiv3::Parameter::Cookie { parameter_data, .. } => &mut parameter_data.format,
};
relax_parameter_format(format, registry);
}
fn relax_parameter_format(
format: &mut openapiv3::ParameterSchemaOrContent,
registry: &openapiv3::Components,
) {
match format {
openapiv3::ParameterSchemaOrContent::Schema(schema) => {
relax_schema_reference(schema, registry)
}
openapiv3::ParameterSchemaOrContent::Content(content) => relax_content(content, registry),
}
}
fn relax_schema_reference(
schema: &mut openapiv3::ReferenceOr<openapiv3::Schema>,
registry: &openapiv3::Components,
) {
if let openapiv3::ReferenceOr::Item(schema) = schema {
relax_schema(schema, registry);
}
}
fn relax_boxed_schema_reference(
schema: &mut openapiv3::ReferenceOr<Box<openapiv3::Schema>>,
registry: &openapiv3::Components,
) {
if let openapiv3::ReferenceOr::Item(schema) = schema {
relax_schema(schema, registry);
}
}
fn relax_schema(schema: &mut openapiv3::Schema, registry: &openapiv3::Components) {
if flattens_objects_beside_scalars(&schema.schema_kind, registry) {
schema.schema_kind = openapiv3::SchemaKind::Any(openapiv3::AnySchema::default());
return;
}
match &mut schema.schema_kind {
openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
relax_additional_properties(&mut object.additional_properties, registry);
for property in object.properties.values_mut() {
relax_boxed_schema_reference(property, registry);
}
}
openapiv3::SchemaKind::Type(openapiv3::Type::Array(array)) => {
if let Some(items) = array.items.as_mut() {
relax_boxed_schema_reference(items, registry);
}
}
openapiv3::SchemaKind::Type(_) => {}
openapiv3::SchemaKind::OneOf { one_of: subschemas }
| openapiv3::SchemaKind::AllOf { all_of: subschemas }
| openapiv3::SchemaKind::AnyOf { any_of: subschemas } => {
for subschema in subschemas {
relax_schema_reference(subschema, registry);
}
}
openapiv3::SchemaKind::Not { not } => relax_schema_reference(not, registry),
openapiv3::SchemaKind::Any(any) => {
relax_additional_properties(&mut any.additional_properties, registry);
for property in any.properties.values_mut() {
relax_boxed_schema_reference(property, registry);
}
if let Some(items) = any.items.as_mut() {
relax_boxed_schema_reference(items, registry);
}
for subschema in any
.one_of
.iter_mut()
.chain(any.all_of.iter_mut())
.chain(any.any_of.iter_mut())
{
relax_schema_reference(subschema, registry);
}
if let Some(not) = any.not.as_mut() {
relax_schema_reference(not, registry);
}
}
}
}
fn relax_additional_properties(
additional_properties: &mut Option<openapiv3::AdditionalProperties>,
registry: &openapiv3::Components,
) {
match additional_properties {
Some(openapiv3::AdditionalProperties::Any(false)) => *additional_properties = None,
Some(openapiv3::AdditionalProperties::Schema(schema)) => {
relax_schema_reference(schema, registry)
}
_ => {}
}
}
fn flattens_objects_beside_scalars(
schema_kind: &openapiv3::SchemaKind,
registry: &openapiv3::Components,
) -> bool {
let subschemas = match schema_kind {
openapiv3::SchemaKind::OneOf { one_of } => one_of,
openapiv3::SchemaKind::AnyOf { any_of } => any_of,
_ => return false,
};
let mut objects = false;
let mut scalars = false;
for subschema in subschemas {
if resolves_to_object(subschema, registry, MAX_SCHEMA_REFERENCE_DEPTH) {
objects = true;
} else {
scalars = true;
}
}
objects && scalars
}
fn resolves_to_object(
schema: &openapiv3::ReferenceOr<openapiv3::Schema>,
registry: &openapiv3::Components,
depth: usize,
) -> bool {
let Some(depth) = depth.checked_sub(1) else {
return false;
};
let schema = match schema {
openapiv3::ReferenceOr::Reference { reference } => {
let Some(target) = reference
.strip_prefix("#/components/schemas/")
.and_then(|name| registry.schemas.get(name))
else {
return false;
};
return resolves_to_object(target, registry, depth);
}
openapiv3::ReferenceOr::Item(schema) => schema,
};
match &schema.schema_kind {
openapiv3::SchemaKind::Type(openapiv3::Type::Object(_)) => true,
openapiv3::SchemaKind::Type(_) => false,
openapiv3::SchemaKind::OneOf { one_of: subschemas }
| openapiv3::SchemaKind::AllOf { all_of: subschemas }
| openapiv3::SchemaKind::AnyOf { any_of: subschemas } => subschemas
.iter()
.any(|subschema| resolves_to_object(subschema, registry, depth)),
openapiv3::SchemaKind::Not { .. } => false,
openapiv3::SchemaKind::Any(any) => {
any.typ.as_deref() == Some("object")
|| !any.properties.is_empty()
|| any.additional_properties.is_some()
}
}
}
struct Face {
css_family: String,
weight: u64,
+291 -4
View File
@@ -10562,7 +10562,14 @@
},
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
"guild_activity_log_presentation": {
"$ref": "#/components/schemas/GuildActivityLogPresentationConfigResponse"
},
"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"},
"expression_info_card": {"$ref": "#/components/schemas/ExpressionInfoCardConfigResponse"},
"registration": {
"type": "object",
"properties": {
@@ -10965,7 +10972,12 @@
"sso",
"gateway_rollout",
"voice_noise_suppression",
"guild_activity_log_presentation",
"experiment_delivery",
"message_hover_tracking",
"message_keyboard_focus",
"blocked_message_groups",
"expression_info_card",
"registration",
"self_hosted",
"app_public",
@@ -11099,10 +11111,30 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
},
"guild_activity_log_presentation": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/GuildActivityLogPresentationConfigUpdateRequest"}]
},
"experiment_delivery": {
"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"}]
},
"expression_info_card": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ExpressionInfoCardConfigUpdateRequest"}]
},
"registration": {
"nullable": true,
"type": "object",
@@ -14917,7 +14949,10 @@
"nullable": true,
"type": "string"
},
"reason": {"description": "The reason provided for the action", "type": "string"},
"reason": {
"description": "The audit log reason. For bans and timeouts without an X-Audit-Log-Reason header this is the reason sent in the request body",
"type": "string"
},
"options": {
"description": "Additional options depending on action type",
"type": "object",
@@ -14925,15 +14960,22 @@
"channel_id": {"description": "Channel ID for relevant actions", "type": "string"},
"count": {"description": "Count of items affected", "type": "number"},
"delete_member_days": {
"description": "Number of days of messages to delete on member ban",
"description": "Deprecated. Whole days of messages deleted, written only by bans recorded before delete_message_seconds",
"type": "string"
},
"delete_message_seconds": {
"description": "Seconds of the banned user's messages that the ban deleted, present only when positive",
"type": "number"
},
"id": {"description": "ID of the affected entity", "type": "string"},
"integration_type": {"description": "Type of integration", "type": "number"},
"message_id": {"description": "Message ID for relevant actions", "type": "string"},
"members_removed": {"description": "Number of members removed", "type": "number"},
"role_name": {"description": "Name of the role", "type": "string"},
"type": {"description": "Type identifier", "type": "number"},
"role_name": {"description": "Name of the role when the entry was written", "type": "string"},
"type": {
"description": "Channel type for CHANNEL_CREATE, CHANNEL_UPDATE and CHANNEL_DELETE. Overwrite target type (0 role, 1 member) for CHANNEL_OVERWRITE_*",
"type": "number"
},
"inviter_id": {"description": "ID of the user who created the invite", "type": "string"},
"max_age": {"description": "Maximum age of the invite in seconds", "type": "number"},
"max_uses": {"description": "Maximum number of uses for the invite", "type": "number"},
@@ -15159,6 +15201,78 @@
"enum": ["open", "approval", "closed"],
"type": "string"
},
"ExpressionInfoCardConfigUpdateRequest": {
"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}$"}
}
}
},
"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": {
@@ -15166,6 +15280,24 @@
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
}
},
"GuildActivityLogPresentationConfigUpdateRequest": {
"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}$"}
}
}
},
"VoiceNoiseSuppressionConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15223,6 +15355,126 @@
"type": "string",
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"]
},
"ExpressionInfoCardConfigResponse": {
"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": "expression-info-card-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
},
"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": {
@@ -15232,6 +15484,41 @@
"required": ["poll_interval_seconds", "poll_jitter_percent"],
"additionalProperties": false
},
"GuildActivityLogPresentationConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {
"default": "guild-activity-log-presentation-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
},
"VoiceNoiseSuppressionConfigResponse": {
"type": "object",
"properties": {
@@ -24,6 +24,16 @@ 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,
#[serde(default)]
pub guild_activity_log_presentation: GuildActivityLogPresentationConfigResponse,
#[serde(default)]
pub expression_info_card: ExpressionInfoCardConfigResponse,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -537,6 +547,196 @@ 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 GuildActivityLogPresentationConfigResponse {
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 GuildActivityLogPresentationConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: "guild-activity-log-presentation-v1".to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct GuildActivityLogPresentationConfigUpdateRequest {
#[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 ExpressionInfoCardConfigResponse {
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 ExpressionInfoCardConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: "expression-info-card-v1".to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ExpressionInfoCardConfigUpdateRequest {
#[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 +854,16 @@ 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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_activity_log_presentation: Option<GuildActivityLogPresentationConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expression_info_card: Option<ExpressionInfoCardConfigUpdateRequest>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -988,12 +1198,48 @@ 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 activity_log =
serde_json::from_value::<GuildActivityLogPresentationConfigResponse>(json!({}))
.expect("default guild activity log presentation config");
let expression = serde_json::from_value::<ExpressionInfoCardConfigResponse>(json!({}))
.expect("default expression info card 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 activity_log = serde_json::to_value(activity_log)
.expect("serializable guild activity log presentation config");
let expression =
serde_json::to_value(expression).expect("serializable expression info card 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");
let generated_activity_log: generated_types::GuildActivityLogPresentationConfigResponse =
serde_json::from_value(activity_log.clone())
.expect("generated guild activity log presentation config contract");
let generated_expression: generated_types::ExpressionInfoCardConfigResponse =
serde_json::from_value(expression.clone())
.expect("generated expression info card config contract");
assert_eq!(
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
noise
@@ -1003,9 +1249,39 @@ 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
);
assert_eq!(
serde_json::to_value(generated_activity_log)
.expect("serializable generated guild activity log presentation config"),
activity_log
);
assert_eq!(
serde_json::to_value(generated_expression)
.expect("serializable generated expression info card config"),
expression
);
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("ExperimentDeliveryConfigResponse", delivery),
("MessageHoverTrackingConfigResponse", hover),
("MessageKeyboardFocusConfigResponse", keyboard),
("BlockedMessageGroupsConfigResponse", blocked),
("GuildActivityLogPresentationConfigResponse", activity_log),
("ExpressionInfoCardConfigResponse", expression),
] {
for (field, value) in value.as_object().expect("config object") {
assert_eq!(
@@ -1016,6 +1292,151 @@ mod tests {
}
}
#[test]
fn generated_client_accepts_unknown_response_fields() {
const GENERATED_CLIENT: &str =
include_str!(concat!(env!("OUT_DIR"), "/admin_api_generated.rs"));
assert!(
!GENERATED_CLIENT.contains("deny_unknown_fields"),
"fluxer_admin/build.rs must clear additionalProperties so a new API field cannot \
blank an admin page"
);
let mut section = serde_json::to_value(ExpressionInfoCardConfigResponse::default())
.expect("serializable expression info card config");
section
.as_object_mut()
.expect("expression info card object")
.insert("future_knob".to_owned(), json!(7));
serde_json::from_value::<generated_types::ExpressionInfoCardConfigResponse>(section)
.expect("generated instance config section tolerates unknown fields");
}
#[test]
fn generated_audit_log_change_accepts_scalar_and_object_values() {
for value in [json!("old"), json!(7), json!(true), json!(null)] {
let change = serde_json::from_value::<generated_types::AuditLogChangeSchema>(
json!({"key": "name", "old_value": value, "new_value": {"added": [], "removed": []}}),
)
.expect("generated audit log change tolerates scalar values");
assert_eq!(change.key, "name");
}
}
#[test]
fn expression_info_card_update_preserves_empty_lists_and_omitted_fields() {
let update = ExpressionInfoCardConfigUpdateRequest {
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::ExpressionInfoCardConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(ExpressionInfoCardConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[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 guild_activity_log_presentation_update_preserves_empty_lists_and_omitted_fields() {
let update = GuildActivityLogPresentationConfigUpdateRequest {
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::GuildActivityLogPresentationConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(GuildActivityLogPresentationConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[test]
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
let update = VoiceNoiseSuppressionConfigUpdateRequest {
+217 -46
View File
@@ -6,21 +6,23 @@ use crate::{
types::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
DeferredPhoneGateUpdateRequest, ExperimentDeliveryConfigUpdateRequest,
AppSetupConfigUpdateRequest, BlockedMessageGroupsConfigUpdateRequest,
CreateRegistrationUrlRequest, DeferredPhoneGateUpdateRequest,
ExperimentDeliveryConfigUpdateRequest, ExpressionInfoCardConfigUpdateRequest,
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,
GuildActivityLogPresentationConfigUpdateRequest, 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 +209,28 @@ 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_guild_activity_log_presentation" => {
match build_guild_activity_log_presentation_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
}
}
"update_expression_info_card" => match build_expression_info_card_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 +471,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 +500,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 +561,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 +627,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 +654,157 @@ 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_guild_activity_log_presentation_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
guild_activity_log_presentation: Some(GuildActivityLogPresentationConfigUpdateRequest {
enabled: Some(form.bool_value("guild_activity_log_presentation_enabled")),
rollout_basis_points: parse_form_number(
form,
"guild_activity_log_presentation_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(
form,
"guild_activity_log_presentation_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("guild_activity_log_presentation_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("guild_activity_log_presentation_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_expression_info_card_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
expression_info_card: Some(ExpressionInfoCardConfigUpdateRequest {
enabled: Some(form.bool_value("expression_card_enabled")),
rollout_basis_points: parse_form_number(
form,
"expression_card_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "expression_card_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("expression_card_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("expression_card_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_experiment_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
@@ -1305,9 +1481,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 +1496,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 +1514,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 +1523,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,9 +2,12 @@
use crate::{
api::types::{
AppPublicConfigResponse, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse,
AppPublicConfigResponse, BlockedMessageGroupsConfigResponse,
ExperimentDeliveryConfigResponse, ExpressionInfoCardConfigResponse,
GatewayRolloutConfigResponse, GuildActivityLogPresentationConfigResponse,
InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse,
InstancePolicyResponse, InstanceRegistrationResponse, LimitConfigResponse,
MessageHoverTrackingConfigResponse, MessageKeyboardFocusConfigResponse,
NoiseSuppressionBackend, PendingRegistrationResponse, RegistrationUrlResponse,
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
VoiceNoiseSuppressionConfigResponse,
@@ -148,6 +151,11 @@ 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))
(guild_activity_log_presentation_section(base, csrf_token, &instance_config.guild_activity_log_presentation))
(expression_info_card_section(base, csrf_token, &instance_config.expression_info_card))
(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 +1183,525 @@ 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 guild_activity_log_presentation_section(
base: &str,
csrf_token: &str,
guild_activity_log_presentation: &GuildActivityLogPresentationConfigResponse,
) -> Markup {
let status = if guild_activity_log_presentation.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = guild_activity_log_presentation.included_user_ids.join("\n");
let excluded_user_ids = guild_activity_log_presentation.excluded_user_ids.join("\n");
section_card_with_description(
"Guild Activity Log Presentation",
"Picks which activity log rendering targeted clients run in community settings. A \
targeted client renders each activity log entry through the rewritten presenters. \
While the master switch below is off every client keeps the previous activity log \
rendering, whatever the rest of these fields say.",
html! {
form method="post" action={(base) "/instance-config?action=update_guild_activity_log_presentation"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (guild_activity_log_presentation.config_version)
}
}
(checkbox(
"guild_activity_log_presentation_enabled",
"true",
"Serve guild activity log presentation assignments to clients",
guild_activity_log_presentation.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 the previous activity log 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(
"guild_activity_log_presentation_rollout_basis_points",
"Rollout (basis points)",
&guild_activity_log_presentation.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"guild_activity_log_presentation_rollout_salt",
"Rollout Salt",
&guild_activity_log_presentation.rollout_salt,
"guild-activity-log-presentation-v1",
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"guild_activity_log_presentation_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save. Blank entries and duplicate \
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"guild_activity_log_presentation_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage, so this is the per-user kill switch."
}
}
(form_actions(html! {
(submit_button("Save Guild Activity Log Presentation Configuration"))
}))
}
}
},
)
}
fn expression_info_card_section(
base: &str,
csrf_token: &str,
expression_info_card: &ExpressionInfoCardConfigResponse,
) -> Markup {
let status = if expression_info_card.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = expression_info_card.included_user_ids.join("\n");
let excluded_user_ids = expression_info_card.excluded_user_ids.join("\n");
section_card_with_description(
"Expression Info Card",
"Picks what a targeted client shows for an emoji or a sticker in a message. A targeted \
client opens a click-triggered info card that names the expression, says where it comes \
from, and offers a row for the source community the reader can open. While the master \
switch below is off every client keeps the hover tooltip it ships with, whatever the \
rest of these fields say.",
html! {
form method="post" action={(base) "/instance-config?action=update_expression_info_card"} {
(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 " (expression_info_card.config_version)
}
}
(checkbox(
"expression_card_enabled",
"true",
"Serve expression info card assignments to clients",
expression_info_card.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 tooltip, so the rollout \
and targeting fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"expression_card_rollout_basis_points",
"Rollout (basis points)",
&expression_info_card.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(
"expression_card_rollout_salt",
"Rollout Salt",
&expression_info_card.rollout_salt,
"expression-info-card-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(
"expression_card_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(
"expression_card_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 Expression Info Card Configuration"))
}))
}
}
},
)
}
fn experiment_delivery_section(
base: &str,
csrf_token: &str,
+376
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use fluxer_admin::api::generated::types as generated_types;
use fluxer_admin::api::types;
#[test]
@@ -247,6 +248,381 @@ fn deserialize_audit_logs_response() {
assert_eq!(resp.logs[0].metadata.get("session_count").unwrap(), "3");
}
#[test]
fn deserialize_guild_audit_logs_with_unknown_keys() {
let json = r#"{
"audit_log_entries": [
{
"id": "1508822460457747581",
"action_type": 22,
"user_id": "1130650140672000000",
"target_id": "1130958221824557056",
"reason": "spam",
"options": {
"delete_message_seconds": 3600,
"future_option": {"nested": true}
},
"changes": [
{"key": "future_change", "future_field": 1}
],
"future_entry_field": "ignored"
},
{
"id": "1508822460457747580",
"action_type": 31,
"user_id": "1130650140672000000",
"target_id": "1130958221824557057",
"options": {
"id": "1130958221824557057",
"type": 0,
"channel_id": "1130958221824557058",
"role_name": "Moderators"
},
"changes": [
{"key": "permissions_diff", "new_value": {"added": ["SEND_MESSAGES"], "removed": []}}
]
},
{
"id": "1508822460457747579",
"action_type": 11,
"user_id": "1130650140672000000",
"target_id": "1130958221824557059",
"options": {"type": 998},
"changes": [
{"key": "name", "old_value": "old-name", "new_value": "new-name"},
{"key": "position", "old_value": 3, "new_value": 4},
{"key": "nsfw", "new_value": true},
{"key": "roles", "new_value": ["1130958221824557060", "1130958221824557061"]}
]
}
],
"users": [],
"webhooks": []
}"#;
let resp: generated_types::ListGuildAuditLogsResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.audit_log_entries.len(), 3);
let ban = &resp.audit_log_entries[0];
assert_eq!(*ban.action_type, 22);
assert_eq!(ban.reason.as_deref(), Some("spam"));
assert!(ban.options.is_some());
assert_eq!(ban.changes.len(), 1);
assert_eq!(ban.changes[0].key, "future_change");
assert!(ban.changes[0].new_value.is_none());
let role_update = &resp.audit_log_entries[1];
let options = role_update.options.as_ref().unwrap();
assert_eq!(options.role_name.as_deref(), Some("Moderators"));
assert_eq!(options.channel_id.as_deref(), Some("1130958221824557058"));
assert_eq!(options.type_, Some(0.0));
assert_eq!(role_update.changes[0].key, "permissions_diff");
assert!(role_update.changes[0].new_value.is_some());
let channel_update = &resp.audit_log_entries[2];
assert_eq!(channel_update.changes.len(), 4);
assert_eq!(
channel_update.changes[0]
.old_value
.as_ref()
.and_then(|value| value.as_str()),
Some("old-name")
);
assert_eq!(
channel_update.changes[0]
.new_value
.as_ref()
.and_then(|value| value.as_str()),
Some("new-name")
);
assert_eq!(
channel_update.changes[1]
.new_value
.as_ref()
.and_then(|value| value.as_i64()),
Some(4)
);
assert_eq!(
channel_update.changes[2]
.new_value
.as_ref()
.and_then(|value| value.as_bool()),
Some(true)
);
assert_eq!(
channel_update.changes[3]
.new_value
.as_ref()
.and_then(|value| value.as_array())
.map(|value| value.len()),
Some(2)
);
}
#[test]
fn deserialize_instance_config_response_with_unknown_keys() {
let json = r##"{
"sso": {
"enabled": true,
"enforced": false,
"display_name": "Fluxer SSO",
"issuer": "https://id.example.com",
"authorization_url": "https://id.example.com/authorize",
"token_url": "https://id.example.com/token",
"userinfo_url": "https://id.example.com/userinfo",
"jwks_url": "https://id.example.com/jwks.json",
"client_id": "fluxer-admin",
"client_secret_set": true,
"scope": "openid profile email",
"allowed_domains": ["fluxer.com"],
"auto_provision": true,
"redirect_uri": "https://fluxer.com/sso/callback"
},
"gateway_rollout": {
"session_rollout_percentage": 100,
"session_rollout_mode": "modulo",
"guild_rollout_percentage": 100,
"rpc_request_timeout_ms": 10000,
"max_concurrent_session_starts": 512,
"max_concurrent_guild_starts": 256,
"gateway_dispatch_relay_shards": 32,
"gateway_dispatch_relay_max_queue": 50000,
"voice_e2ee_scope": "guild_feature_only",
"future_rollout_knob": 3
},
"voice_noise_suppression": {
"enabled": true,
"config_version": 4,
"default_backend": "rnnoise",
"enabled_backends": ["none", "standard", "rnnoise"],
"allow_user_override": true,
"rollout_basis_points": 10000,
"rollout_salt": "voice-ns-v1",
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"stereo_enabled": false,
"suppression_strength": 80
},
"guild_activity_log_presentation": {
"enabled": true,
"config_version": 2,
"rollout_basis_points": 5000,
"rollout_salt": "guild-activity-log-presentation-v1",
"included_user_ids": ["1130650140672000000"],
"excluded_user_ids": [],
"future_presentation_knob": "verbose"
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"message_hover_tracking": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "message-hover-tracking-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"message_keyboard_focus": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "message-keyboard-focus-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"blocked_message_groups": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "blocked-message-groups-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"expression_info_card": {
"enabled": true,
"config_version": 3,
"rollout_basis_points": 2500,
"rollout_salt": "expression-info-card-v1",
"included_user_ids": ["1130650140672000000"],
"excluded_user_ids": ["1130958221824557056"],
"future_knob": 7,
"future_object_knob": {"nested": true},
"future_list_knob": ["a", "b"]
},
"registration": {
"mode": "open",
"admin_registration_urls_enabled": false,
"urls": [],
"pending_registrations": []
},
"self_hosted": false,
"app_public": {
"branding": {
"product_name": "Fluxer",
"icon_url": "https://cdn.example.com/icon.png",
"symbol_url": "https://cdn.example.com/symbol.svg",
"logo_url": "https://cdn.example.com/logo.svg",
"wordmark_url": "https://cdn.example.com/wordmark.svg",
"favicon_url": "https://cdn.example.com/favicon.ico",
"theme_color": "#5865f2",
"future_asset_url": "https://cdn.example.com/future.png"
},
"setup": {"configured": true},
"legal": {
"terms_url": "https://fluxer.com/terms",
"privacy_url": "https://fluxer.com/privacy"
},
"registration": {"collect_date_of_birth": true}
},
"policy": {
"single_community_enabled": false,
"single_community_guild_id": null,
"direct_messages_disabled": false,
"direct_messages_locked": false,
"premium_mode": "mirror",
"services": {
"gif_enabled": true,
"youtube_enabled": true,
"bluesky_enabled": false,
"future_service_enabled": true
},
"services_resolved": {
"gif_enabled": true,
"youtube_enabled": true,
"bluesky_enabled": false
},
"services_available": {"gif": true, "youtube": true, "bluesky": false},
"deferred_phone_gate": {
"enabled": false,
"window_hours": 24,
"member_threshold": 100
}
},
"integrations": {
"gif": {"klipy_api_key_set": true, "effective_available": true},
"youtube": {"api_key_set": true, "effective_available": true},
"captcha": {
"provider": "hcaptcha",
"effective_provider": "hcaptcha",
"hcaptcha_site_key": "site",
"hcaptcha_secret_key_set": true,
"turnstile_site_key": "",
"turnstile_secret_key_set": false,
"effective_enabled": true
},
"email": {
"enabled": true,
"effective_enabled": true,
"provider": "smtp",
"effective_provider": "smtp",
"from_email": "[email protected]",
"from_name": "Fluxer",
"smtp": {
"host": "smtp.example.com",
"port": 587,
"username": "fluxer",
"password_set": true,
"secure": true
},
"disable_new_ip_authorization": false,
"effective_disable_new_ip_authorization": false
},
"bluesky": {
"enabled": false,
"effective_enabled": false,
"client_name": "Fluxer",
"client_uri": "https://fluxer.com",
"logo_uri": "https://cdn.example.com/logo.svg",
"tos_uri": "https://fluxer.com/terms",
"policy_uri": "https://fluxer.com/privacy",
"key_count": 0
}
},
"media": {
"attachment_decay": {
"enabled": true,
"min_size_mb": 10,
"max_size_mb": 500,
"max_eligible_size_mb": 500,
"min_lifetime_days": 30,
"max_lifetime_days": 365,
"curve": 2,
"renew_threshold_days": 7,
"renew_window_days": 14,
"effective": {
"enabled": true,
"min_size_mb": 10,
"max_size_mb": 500,
"max_eligible_size_mb": 500,
"min_lifetime_days": 30,
"max_lifetime_days": 365,
"curve": 2,
"renew_threshold_days": 7,
"renew_window_days": 14,
"future_curve": 1.5
}
}
},
"future_section": {"enabled": true, "rollout_basis_points": 10000},
"future_flag": 3
}"##;
let resp: generated_types::InstanceConfigResponse = serde_json::from_str(json).expect(
"the build.rs relaxation must keep unknown response keys from failing the whole response",
);
assert!(!resp.self_hosted);
assert!(resp.expression_info_card.enabled);
assert_eq!(resp.expression_info_card.config_version, 3);
assert_eq!(resp.expression_info_card.rollout_basis_points, 2500);
assert_eq!(
*resp.expression_info_card.rollout_salt,
"expression-info-card-v1"
);
assert_eq!(resp.expression_info_card.included_user_ids.len(), 1);
assert_eq!(
*resp.expression_info_card.excluded_user_ids[0],
"1130958221824557056"
);
assert!(resp.guild_activity_log_presentation.enabled);
assert_eq!(
*resp.guild_activity_log_presentation.rollout_salt,
"guild-activity-log-presentation-v1"
);
assert!(!resp.message_hover_tracking.enabled);
assert!(!resp.message_keyboard_focus.enabled);
assert!(!resp.blocked_message_groups.enabled);
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
assert!(resp.policy.single_community_guild_id.is_none());
assert_eq!(resp.policy.services.gif_enabled, Some(true));
assert_eq!(resp.app_public.branding.product_name, "Fluxer");
assert!(resp.media.attachment_decay.effective.enabled);
let without_unknown_keys = json
.replace("\"future_rollout_knob\": 3,", "")
.replace("\"future_presentation_knob\": \"verbose\",", "")
.replace("\"future_knob\": 7,", "")
.replace("\"future_object_knob\": {\"nested\": true},", "")
.replace("\"future_list_knob\": [\"a\", \"b\"],", "")
.replace(
"\"future_asset_url\": \"https://cdn.example.com/future.png\",",
"",
)
.replace("\"future_service_enabled\": true,", "")
.replace("\"future_curve\": 1.5,", "")
.replace(
"\"future_section\": {\"enabled\": true, \"rollout_basis_points\": 10000},",
"",
)
.replace("\"future_flag\": 3,", "");
let baseline: generated_types::InstanceConfigResponse =
serde_json::from_str(&without_unknown_keys).expect("known keys alone still deserialize");
assert_eq!(
serde_json::to_value(&baseline).unwrap(),
serde_json::to_value(&resp).unwrap()
);
}
#[test]
fn deserialize_search_reports_response() {
let json = r#"{
@@ -31,7 +31,12 @@ 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 {ExpressionInfoCardConfigSchema} from '@fluxer/schema/src/domains/experiment/ExpressionInfoCardSchemas';
import {GuildActivityLogPresentationConfigSchema} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
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';
@@ -57,7 +62,12 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
ssoConfig,
gatewayRollout,
voiceNoiseSuppression,
guildActivityLogPresentation,
experimentDelivery,
messageHoverTracking,
messageKeyboardFocus,
blockedMessageGroups,
expressionInfoCard,
registrationConfig,
registrationUrls,
pendingRegistrations,
@@ -65,7 +75,12 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getSsoConfig(),
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getGuildActivityLogPresentationConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getMessageHoverTrackingConfig(),
instanceConfigRepository.getMessageKeyboardFocusConfig(),
instanceConfigRepository.getBlockedMessageGroupsConfig(),
instanceConfigRepository.getExpressionInfoCardConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
instanceConfigRepository.getPendingRegistrations(),
@@ -96,7 +111,12 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
},
gateway_rollout: gatewayRollout,
voice_noise_suppression: voiceNoiseSuppression,
guild_activity_log_presentation: guildActivityLogPresentation,
experiment_delivery: experimentDelivery,
message_hover_tracking: messageHoverTracking,
message_keyboard_focus: messageKeyboardFocus,
blocked_message_groups: blockedMessageGroups,
expression_info_card: expressionInfoCard,
registration: {
...registrationConfig,
urls: registrationUrls,
@@ -240,6 +260,67 @@ export function InstanceConfigAdminController(app: HonoApp) {
await instanceConfigRepository.setVoiceNoiseSuppressionConfig(validated);
}
}
if (data.guild_activity_log_presentation) {
const patch = omitUndefinedFields(data.guild_activity_log_presentation);
if (Object.keys(patch).length > 0) {
const currentGuildActivityLogPresentation =
await instanceConfigRepository.getGuildActivityLogPresentationConfig();
const validated = GuildActivityLogPresentationConfigSchema.parse({
...currentGuildActivityLogPresentation,
...patch,
config_version: currentGuildActivityLogPresentation.config_version + 1,
});
await instanceConfigRepository.setGuildActivityLogPresentationConfig(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.expression_info_card) {
const patch = omitUndefinedFields(data.expression_info_card);
if (Object.keys(patch).length > 0) {
const currentExpressionInfoCard = await instanceConfigRepository.getExpressionInfoCardConfig();
const validated = ExpressionInfoCardConfigSchema.parse({
...currentExpressionInfoCard,
...patch,
config_version: currentExpressionInfoCard.config_version + 1,
});
await instanceConfigRepository.setExpressionInfoCardConfig(validated);
}
}
if (data.experiment_delivery) {
const currentExperimentDelivery = await instanceConfigRepository.getExperimentDeliveryConfig();
const validated = ExperimentDeliveryConfigSchema.parse({
@@ -156,6 +156,7 @@ export function ChannelController(app: HonoApp) {
const data = ctx.req.valid('json');
const clientFeatures = parseClientFeaturesHeader(ctx.req.header(CLIENT_FEATURES_HEADER));
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
const channelRequestService = ctx.get('channelRequestService');
return ctx.json(
await channelRequestService.updateChannel({
@@ -164,6 +165,7 @@ export function ChannelController(app: HonoApp) {
data,
clientFeatures,
requestCache,
auditLogReason,
}),
);
},
@@ -195,6 +197,7 @@ export function ChannelController(app: HonoApp) {
const {silent, delete_messages} = ctx.req.valid('query');
const body = ctx.req.valid('json');
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
const channelRequestService = ctx.get('channelRequestService');
await ctx.get('channelService').channelData.operations.getChannel({userId, channelId});
if (delete_messages) {
@@ -203,7 +206,7 @@ export function ChannelController(app: HonoApp) {
channelIds: [channelId],
});
}
await channelRequestService.deleteChannel({userId, channelId, requestCache, silent});
await channelRequestService.deleteChannel({userId, channelId, requestCache, silent, auditLogReason});
return ctx.body(null, 204);
},
);
@@ -300,6 +303,7 @@ export function ChannelController(app: HonoApp) {
const data = ctx.req.valid('json');
const clientFeatures = parseClientFeaturesHeader(ctx.req.header(CLIENT_FEATURES_HEADER));
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('channelService').channelData.operations.setChannelPermissionOverwrite({
userId,
channelId,
@@ -311,6 +315,7 @@ export function ChannelController(app: HonoApp) {
},
clientFeatures,
requestCache,
auditLogReason,
});
return ctx.body(null, 204);
},
@@ -335,9 +340,14 @@ export function ChannelController(app: HonoApp) {
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const overwriteId = ctx.req.valid('param').overwrite_id;
const requestCache = ctx.get('requestCache');
await ctx
.get('channelService')
.channelData.operations.deleteChannelPermissionOverwrite({userId, channelId, overwriteId, requestCache});
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('channelService').channelData.operations.deleteChannelPermissionOverwrite({
userId,
channelId,
overwriteId,
requestCache,
auditLogReason,
});
return ctx.body(null, 204);
},
);
@@ -364,7 +364,10 @@ export function MessageController(app: HonoApp) {
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const requestCache = ctx.get('requestCache');
await ctx.get('channelService').messages.deletion.deleteMessage({userId, channelId, messageId, requestCache});
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx
.get('channelService')
.messages.deletion.deleteMessage({userId, channelId, messageId, requestCache, auditLogReason});
return ctx.body(null, 204);
},
);
@@ -420,7 +423,10 @@ export function MessageController(app: HonoApp) {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const messageIds = ctx.req.valid('json').message_ids.map(createMessageID);
await ctx.get('channelService').messages.deletion.bulkDeleteMessages({userId, channelId, messageIds});
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx
.get('channelService')
.messages.deletion.bulkDeleteMessages({userId, channelId, messageIds, auditLogReason});
return ctx.body(null, 204);
},
);
@@ -106,11 +106,13 @@ export function MessageInteractionController(app: HonoApp) {
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('channelService').interactions.pinMessage({
userId,
channelId,
messageId,
requestCache,
auditLogReason,
});
return ctx.body(null, 204);
},
@@ -136,11 +138,13 @@ export function MessageInteractionController(app: HonoApp) {
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('channelService').interactions.unpinMessage({
userId,
channelId,
messageId,
requestCache,
auditLogReason,
});
return ctx.body(null, 204);
},
@@ -104,12 +104,14 @@ export class ChannelDataService {
data,
clientFeatures,
requestCache,
auditLogReason,
}: {
userId: UserID;
channelId: ChannelID;
data: Omit<ChannelUpdateRequest, 'type'>;
clientFeatures: ReadonlySet<string>;
requestCache: RequestCache;
auditLogReason: string | null;
}): Promise<Channel> {
const {channel} = await this.auth.getChannelAuthenticated({userId, channelId, skipNsfwValidation: true});
if (channel.type === ChannelTypes.GROUP_DM) {
@@ -182,6 +184,7 @@ export class ChannelDataService {
data: channelUpdateData,
clientFeatures,
requestCache,
auditLogReason,
});
}
}
@@ -61,6 +61,7 @@ export class ChannelRequestService {
data: ChannelUpdateRequest;
clientFeatures: ReadonlySet<string>;
requestCache: RequestCache;
auditLogReason: string | null;
}): Promise<ChannelResponse> {
const channel = await this.channelService.channelData.editChannel({
userId: params.userId,
@@ -68,6 +69,7 @@ export class ChannelRequestService {
data: params.data,
clientFeatures: params.clientFeatures,
requestCache: params.requestCache,
auditLogReason: params.auditLogReason,
});
return mapChannelToResponse({
channel,
@@ -82,6 +84,7 @@ export class ChannelRequestService {
channelId: ChannelID;
requestCache: RequestCache;
silent?: boolean;
auditLogReason: string | null;
}): Promise<void> {
const channel = await this.channelService.channelData.operations.getChannel({
userId: params.userId,
@@ -101,6 +104,7 @@ export class ChannelRequestService {
userId: params.userId,
channelId: params.channelId,
requestCache: params.requestCache,
auditLogReason: params.auditLogReason,
});
}
}
@@ -97,17 +97,19 @@ export class MessageInteractionService {
channelId,
messageId,
requestCache,
auditLogReason,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
requestCache: RequestCache;
auditLogReason?: string | null;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
if (!authChannel.guild && authChannel.channel.type !== ChannelTypes.DM_PERSONAL_NOTES) {
await this.authService.validateDMSendPermissions({channel: authChannel.channel, userId});
}
await this.pinService.pinMessage({authChannel, messageId, userId, requestCache});
await this.pinService.pinMessage({authChannel, messageId, userId, requestCache, auditLogReason});
}
async unpinMessage({
@@ -115,17 +117,19 @@ export class MessageInteractionService {
channelId,
messageId,
requestCache,
auditLogReason,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
requestCache: RequestCache;
auditLogReason?: string | null;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
if (!authChannel.guild && authChannel.channel.type !== ChannelTypes.DM_PERSONAL_NOTES) {
await this.authService.validateDMSendPermissions({channel: authChannel.channel, userId});
}
await this.pinService.unpinMessage({authChannel, messageId, userId, requestCache});
await this.pinService.unpinMessage({authChannel, messageId, userId, requestCache, auditLogReason});
}
async getUsersForReaction({
@@ -129,12 +129,14 @@ export class ChannelOperationsService {
data,
clientFeatures,
requestCache,
auditLogReason,
}: {
userId: UserID;
channelId: ChannelID;
data: ChannelUpdateData;
clientFeatures: ReadonlySet<string>;
requestCache: RequestCache;
auditLogReason: string | null;
}): Promise<Channel> {
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
userId,
@@ -339,7 +341,7 @@ export class ChannelOperationsService {
const builder = this.guildAuditLogService
.createBuilder(guildIdValue, userId)
.withAction(AuditLogActionType.CHANNEL_UPDATE, channel.id.toString())
.withReason(null)
.withReason(auditLogReason)
.withMetadata({
type: updatedChannel.type.toString(),
})
@@ -358,8 +360,6 @@ export class ChannelOperationsService {
'Failed to record guild audit log',
);
}
if (data.name !== undefined && channel.name !== updatedChannel.name) {
}
}
if (data.permission_overwrites !== undefined) {
await this.guildAuditLogService.recordPermissionOverwriteDiff({
@@ -368,6 +368,7 @@ export class ChannelOperationsService {
channelId: updatedChannel.id,
previous: previousPermissionOverwrites,
next: updatedChannel.permissionOverwrites,
reason: auditLogReason,
});
}
return updatedChannel;
@@ -377,10 +378,12 @@ export class ChannelOperationsService {
userId,
channelId,
requestCache,
auditLogReason,
}: {
userId: UserID;
channelId: ChannelID;
requestCache: RequestCache;
auditLogReason: string | null;
}): Promise<void> {
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
userId,
@@ -421,7 +424,7 @@ export class ChannelOperationsService {
const builder = this.guildAuditLogService
.createBuilder(guildIdValue, userId)
.withAction(AuditLogActionType.CHANNEL_DELETE, channel.id.toString())
.withReason(null)
.withReason(auditLogReason)
.withMetadata({
type: channel.type.toString(),
})
@@ -611,6 +614,7 @@ export class ChannelOperationsService {
};
clientFeatures: ReadonlySet<string>;
requestCache: RequestCache;
auditLogReason: string | null;
}): Promise<void> {
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
if (!channel || !channel.guildId) throw new UnknownChannelError();
@@ -646,15 +650,13 @@ export class ChannelOperationsService {
const removedDeny = (existing?.deny ?? 0n) & ~sanitizedDeny;
if (!hasAdministrator && (removedDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
const previousPermissionOverwrites = channel.permissionOverwrites;
const nextOverwrite = new ChannelPermissionOverwrite({
type: params.overwrite.type,
allow_: sanitizedAllow,
deny_: sanitizedDeny,
});
const overwrites = new Map(channel.permissionOverwrites ?? []);
overwrites.set(
targetId,
new ChannelPermissionOverwrite({
type: params.overwrite.type,
allow_: sanitizedAllow,
deny_: sanitizedDeny,
}),
);
overwrites.set(targetId, nextOverwrite);
const updated = await this.channelRepository.channelData.upsert({
...channel.toRow(),
permission_overwrites: new Map(
@@ -670,50 +672,14 @@ export class ChannelOperationsService {
requestCache: params.requestCache,
});
}
const previousSnapshot = existing
? {
id: params.overwriteId.toString(),
type: existing.type.toString(),
allow: existing.allow.toString(),
deny: existing.deny.toString(),
}
: null;
const nextSnapshot = {
id: params.overwriteId.toString(),
type: params.overwrite.type.toString(),
allow: sanitizedAllow.toString(),
deny: sanitizedDeny.toString(),
};
const changes = this.guildAuditLogService.computeChanges(previousSnapshot, nextSnapshot);
if (changes.length > 0) {
const action = existing
? AuditLogActionType.CHANNEL_OVERWRITE_UPDATE
: AuditLogActionType.CHANNEL_OVERWRITE_CREATE;
const builder = this.guildAuditLogService
.createBuilder(channel.guildId, params.userId)
.withAction(action, params.overwriteId.toString())
.withReason(null)
.withMetadata({
id: params.overwriteId.toString(),
type: params.overwrite.type.toString(),
channel_id: channel.id.toString(),
})
.withChanges(changes);
try {
await builder.commit();
} catch (error) {
Logger.error(
{
error,
guildId: channel.guildId.toString(),
userId: params.userId.toString(),
action,
targetId: params.overwriteId.toString(),
},
'Failed to record guild audit log',
);
}
}
await this.guildAuditLogService.recordPermissionOverwriteDiff({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
previous: existing ? new Map([[targetId, existing]]) : null,
next: new Map([[targetId, nextOverwrite]]),
reason: params.auditLogReason,
});
}
async deleteChannelPermissionOverwrite(params: {
@@ -721,6 +687,7 @@ export class ChannelOperationsService {
channelId: ChannelID;
overwriteId: bigint;
requestCache: RequestCache;
auditLogReason: string | null;
}): Promise<void> {
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
if (!channel || !channel.guildId) throw new UnknownChannelError();
@@ -763,37 +730,15 @@ export class ChannelOperationsService {
});
}
if (removed) {
const previousSnapshot = {
id: params.overwriteId.toString(),
type: removed.type.toString(),
allow: removed.allow.toString(),
deny: removed.deny.toString(),
};
const changes = this.guildAuditLogService.computeChanges(previousSnapshot, null);
const builder = this.guildAuditLogService
.createBuilder(channel.guildId, params.userId)
.withAction(AuditLogActionType.CHANNEL_OVERWRITE_DELETE, params.overwriteId.toString())
.withReason(null)
.withMetadata({
id: params.overwriteId.toString(),
type: removed.type.toString(),
channel_id: channel.id.toString(),
})
.withChanges(changes);
try {
await builder.commit();
} catch (error) {
Logger.error(
{
error,
guildId: channel.guildId.toString(),
userId: params.userId.toString(),
action: AuditLogActionType.CHANNEL_OVERWRITE_DELETE,
targetId: params.overwriteId.toString(),
},
'Failed to record guild audit log',
);
}
const removedTargetId = removed.type === 0 ? createRoleID(params.overwriteId) : createUserID(params.overwriteId);
await this.guildAuditLogService.recordPermissionOverwriteDiff({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
previous: new Map([[removedTargetId, removed]]),
next: null,
reason: params.auditLogReason,
});
}
}
}
@@ -142,11 +142,13 @@ export class MessagePinService extends MessageInteractionBase {
authChannel,
messageId,
userId,
auditLogReason,
}: {
authChannel: AuthenticatedChannel;
messageId: MessageID;
userId: UserID;
requestCache: RequestCache;
auditLogReason?: string | null;
}): Promise<void> {
const {channel, guild, checkPermission} = authChannel;
if (guild) {
@@ -182,7 +184,7 @@ export class MessagePinService extends MessageInteractionBase {
channel_id: channel.id.toString(),
message_id: messageId.toString(),
})
.withReason(null)
.withReason(auditLogReason ?? null)
.commit();
}
}
@@ -191,11 +193,13 @@ export class MessagePinService extends MessageInteractionBase {
authChannel,
messageId,
userId,
auditLogReason,
}: {
authChannel: AuthenticatedChannel;
messageId: MessageID;
userId: UserID;
requestCache: RequestCache;
auditLogReason?: string | null;
}): Promise<void> {
const {channel, guild, checkPermission} = authChannel;
if (guild) {
@@ -227,7 +231,7 @@ export class MessagePinService extends MessageInteractionBase {
channel_id: channel.id.toString(),
message_id: messageId.toString(),
})
.withReason(null)
.withReason(auditLogReason ?? null)
.commit();
}
}
@@ -53,12 +53,14 @@ export class MessageDeleteService {
channelId,
messageId,
skipGuildAuditLog,
auditLogReason,
}: {
userId: UserID;
channelId: ChannelID;
messageId: MessageID;
requestCache: RequestCache;
skipGuildAuditLog?: boolean;
auditLogReason?: string | null;
}): Promise<void> {
const {channel, guild, hasPermission} = await this.deps.channelAuthService.getChannelAuthenticated({
userId,
@@ -97,7 +99,7 @@ export class MessageDeleteService {
.createBuilder(channel.guildId, userId)
.withAction(AuditLogActionType.MESSAGE_DELETE, message.id.toString())
.withMetadata({channel_id: channel.id.toString()})
.withReason(null)
.withReason(auditLogReason ?? null)
.commit();
}
await this.deps.searchService.deleteMessageIndex(messageId);
@@ -149,10 +151,12 @@ export class MessageDeleteService {
userId,
channelId,
messageIds,
auditLogReason,
}: {
userId: UserID;
channelId: ChannelID;
messageIds: Array<MessageID>;
auditLogReason?: string | null;
}): Promise<void> {
if (messageIds.length === 0) {
throw InputValidationError.fromCode('message_ids', ValidationErrorCodes.MESSAGE_IDS_CANNOT_BE_EMPTY);
@@ -186,7 +190,7 @@ export class MessageDeleteService {
channel_id: channel.id.toString(),
count: existingMessages.length.toString(),
})
.withReason(null)
.withReason(auditLogReason ?? null)
.commit();
}
await this.deps.searchService.deleteMessagesIndex(messageIds);
@@ -9,7 +9,12 @@ 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 {resolveExpressionInfoCardAssignment} from '@fluxer/schema/src/domains/experiment/ExpressionInfoCardSchemas';
import {resolveGuildActivityLogPresentationAssignment} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
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 +33,37 @@ export function ExperimentController(app: HonoApp) {
}),
async (ctx) => {
const instanceConfigRepository = ctx.get('instanceConfigRepository');
const [delivery, voiceConfig] = await Promise.all([
const [
delivery,
voiceConfig,
messageHoverTrackingConfig,
messageKeyboardFocusConfig,
blockedMessageGroupsConfig,
guildActivityLogPresentationConfig,
expressionInfoCardConfig,
] = await Promise.all([
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getMessageHoverTrackingConfig(),
instanceConfigRepository.getMessageKeyboardFocusConfig(),
instanceConfigRepository.getBlockedMessageGroupsConfig(),
instanceConfigRepository.getGuildActivityLogPresentationConfig(),
instanceConfigRepository.getExpressionInfoCardConfig(),
]);
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),
guild_activity_log_presentation: resolveGuildActivityLogPresentationAssignment(
guildActivityLogPresentationConfig,
userId,
),
expression_info_card: resolveExpressionInfoCardAssignment(expressionInfoCardConfig, userId),
},
};
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
@@ -10,13 +10,38 @@ 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,
readExpressionInfoCardAssignment,
readGuildActivityLogPresentationAssignment,
readMessageHoverTrackingAssignment,
readMessageKeyboardFocusAssignment,
readVoiceNoiseSuppressionAssignment,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {
DEFAULT_EXPRESSION_INFO_CARD_CONFIG,
INERT_EXPRESSION_INFO_CARD_ASSIGNMENT,
} from '@fluxer/schema/src/domains/experiment/ExpressionInfoCardSchemas';
import {
DEFAULT_GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG,
INERT_GUILD_ACTIVITY_LOG_PRESENTATION_ASSIGNMENT,
} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
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 +74,14 @@ 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,
guild_activity_log_presentation: INERT_GUILD_ACTIVITY_LOG_PRESENTATION_ASSIGNMENT,
expression_info_card: INERT_EXPRESSION_INFO_CARD_ASSIGNMENT,
},
});
});
@@ -80,6 +112,296 @@ 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('populates the guild activity log presentation key even when the rollout is disabled', async () => {
const account = await createTestAccount(harness);
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(Object.hasOwn(body.assignments, 'guild_activity_log_presentation')).toBe(true);
expect(readGuildActivityLogPresentationAssignment(body)).toEqual(INERT_GUILD_ACTIVITY_LOG_PRESENTATION_ASSIGNMENT);
});
it('targets an allowlisted account for guild activity log presentation', async () => {
const targeted = await createTestAccount(harness);
const untargeted = await createTestAccount(harness);
await getInstanceConfigRepository().setGuildActivityLogPresentationConfig({
...DEFAULT_GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG,
enabled: true,
config_version: 6,
included_user_ids: [targeted.userId],
});
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
.get(ENDPOINT)
.execute();
expect(readGuildActivityLogPresentationAssignment(targetedBody)).toEqual({
enabled: true,
config_version: 6,
user_targeted: true,
source: 'user_rule',
});
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
.get(ENDPOINT)
.execute();
expect(readGuildActivityLogPresentationAssignment(untargetedBody)).toEqual({
enabled: true,
config_version: 6,
user_targeted: false,
source: null,
});
});
it('bumps the guild activity log presentation config version on every admin update', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const updated = await createBuilder<{
guild_activity_log_presentation: {config_version: number; enabled: boolean; rollout_basis_points: number};
}>(harness, admin.token)
.patch('/admin/instance/config')
.body({guild_activity_log_presentation: {enabled: true, rollout_basis_points: 10000}})
.execute();
expect(updated.guild_activity_log_presentation).toMatchObject({
config_version: 1,
enabled: true,
rollout_basis_points: 10000,
});
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
expect(readGuildActivityLogPresentationAssignment(body)).toEqual({
enabled: true,
config_version: 1,
user_targeted: true,
source: 'canary',
});
});
it('populates the expression info card 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, 'expression_info_card')).toBe(true);
expect(readExpressionInfoCardAssignment(body)).toEqual(INERT_EXPRESSION_INFO_CARD_ASSIGNMENT);
});
it('targets an allowlisted account for the expression info card', async () => {
const account = await createTestAccount(harness);
await getInstanceConfigRepository().setExpressionInfoCardConfig({
...DEFAULT_EXPRESSION_INFO_CARD_CONFIG,
enabled: true,
config_version: 4,
included_user_ids: [account.userId],
});
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(readExpressionInfoCardAssignment(body)).toEqual({
enabled: true,
config_version: 4,
user_targeted: true,
source: 'user_rule',
});
});
it('leaves an account outside a zero-width expression info card rollout', async () => {
const account = await createTestAccount(harness);
await getInstanceConfigRepository().setExpressionInfoCardConfig({
...DEFAULT_EXPRESSION_INFO_CARD_CONFIG,
enabled: true,
config_version: 2,
});
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
expect(readExpressionInfoCardAssignment(body)).toEqual({
enabled: true,
config_version: 2,
user_targeted: false,
source: null,
});
});
it('resolves all six 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,
});
await getInstanceConfigRepository().setGuildActivityLogPresentationConfig({
...DEFAULT_GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG,
enabled: true,
rollout_basis_points: 10000,
});
await getInstanceConfigRepository().setExpressionInfoCardConfig({
...DEFAULT_EXPRESSION_INFO_CARD_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(readGuildActivityLogPresentationAssignment(body).user_targeted).toBe(true);
expect(readExpressionInfoCardAssignment(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({
@@ -0,0 +1,247 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import type {AuditLogChange, GuildAuditLogChange} from '@app/api/guild/GuildAuditLogTypes';
import type {GuildAuditLog} from '@app/api/models/GuildAuditLog';
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import type {AuditLogOptions, GuildAuditLogEntryResponse} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas';
import {isValidSnowflake} from '@fluxer/snowflake/src/Snowflake';
export interface StoredGuildAuditLogEntryResponse extends Omit<GuildAuditLogEntryResponse, 'changes'> {
changes?: GuildAuditLogChange;
}
type NumericAuditLogOptionKey =
| 'count'
| 'delete_message_seconds'
| 'integration_type'
| 'members_removed'
| 'type'
| 'max_age'
| 'max_uses'
| 'uses';
export const GUILD_AUDIT_INTERNAL_CHANGE_KEYS: ReadonlySet<string> = new Set([
'guild_id',
'member_count',
'banner_width',
'banner_height',
'splash_width',
'splash_height',
'embed_splash_width',
'embed_splash_height',
]);
const NOOP_SKIPPABLE_ACTIONS: ReadonlySet<AuditLogActionType> = new Set([
AuditLogActionType.GUILD_UPDATE,
AuditLogActionType.CHANNEL_UPDATE,
AuditLogActionType.CHANNEL_OVERWRITE_UPDATE,
AuditLogActionType.MEMBER_UPDATE,
AuditLogActionType.MEMBER_ROLE_UPDATE,
AuditLogActionType.MEMBER_MOVE,
AuditLogActionType.ROLE_UPDATE,
AuditLogActionType.WEBHOOK_UPDATE,
AuditLogActionType.EMOJI_UPDATE,
AuditLogActionType.STICKER_UPDATE,
]);
const USER_TARGET_ACTIONS: ReadonlySet<AuditLogActionType> = new Set([
AuditLogActionType.MEMBER_KICK,
AuditLogActionType.MEMBER_PRUNE,
AuditLogActionType.MEMBER_BAN_ADD,
AuditLogActionType.MEMBER_BAN_REMOVE,
AuditLogActionType.MEMBER_UPDATE,
AuditLogActionType.MEMBER_ROLE_UPDATE,
AuditLogActionType.MEMBER_MOVE,
AuditLogActionType.MEMBER_DISCONNECT,
AuditLogActionType.BOT_ADD,
]);
const CREATOR_CHANGE_ACTIONS: ReadonlySet<AuditLogActionType> = new Set([
AuditLogActionType.WEBHOOK_DELETE,
AuditLogActionType.EMOJI_DELETE,
AuditLogActionType.STICKER_DELETE,
]);
const OVERWRITE_ACTIONS: ReadonlySet<AuditLogActionType> = new Set([
AuditLogActionType.CHANNEL_OVERWRITE_CREATE,
AuditLogActionType.CHANNEL_OVERWRITE_UPDATE,
AuditLogActionType.CHANNEL_OVERWRITE_DELETE,
]);
const SNOWFLAKE_PATTERN = /^\d{1,20}$/;
export function isNoopGuildAuditLog(
actionType: AuditLogActionType,
changes: GuildAuditLogChange | null | undefined,
): boolean {
if (!NOOP_SKIPPABLE_ACTIONS.has(actionType)) {
return false;
}
if (!changes) {
return true;
}
return !changes.some(
(change) =>
change.key !== 'ip' &&
!(actionType === AuditLogActionType.GUILD_UPDATE && GUILD_AUDIT_INTERNAL_CHANGE_KEYS.has(change.key)),
);
}
export function mapGuildAuditLogEntry(log: GuildAuditLog): StoredGuildAuditLogEntryResponse {
return {
id: log.logId.toString(),
action_type: log.actionType,
user_id: log.userId.toString(),
target_id: log.targetId,
reason: resolveEntryReason(log),
options: buildAuditLogOptions(log.options),
changes: scrubSensitiveChanges(log.changes),
};
}
export function collectGuildAuditLogUserIds(log: GuildAuditLog): Array<UserID> {
const userIds = new Set<UserID>([log.userId]);
const addUserId = (value: unknown) => {
const userId = parseUserId(value);
if (userId !== null) {
userIds.add(userId);
}
};
if (USER_TARGET_ACTIONS.has(log.actionType)) {
addUserId(log.targetId);
}
if (log.actionType === AuditLogActionType.GUILD_UPDATE) {
addUserId(findChange(log.changes, 'owner_id')?.new_value);
}
if (log.actionType === AuditLogActionType.MEMBER_BAN_REMOVE) {
addUserId(findChange(log.changes, 'moderator_id')?.old_value);
}
if (log.actionType === AuditLogActionType.INVITE_DELETE) {
addUserId(log.options.get('inviter_id'));
}
if (CREATOR_CHANGE_ACTIONS.has(log.actionType)) {
addUserId(findChange(log.changes, 'creator_id')?.old_value);
}
if (OVERWRITE_ACTIONS.has(log.actionType) && log.options.get('type') === '1') {
addUserId(log.targetId);
}
return Array.from(userIds);
}
function resolveEntryReason(log: GuildAuditLog): string | undefined {
const explicitReason = readNonBlankString(log.reason);
if (explicitReason !== null) {
return explicitReason;
}
if (log.actionType === AuditLogActionType.MEMBER_BAN_ADD) {
return readNonBlankString(findChange(log.changes, 'reason')?.new_value) ?? undefined;
}
if (log.actionType === AuditLogActionType.MEMBER_UPDATE) {
return readNonBlankString(log.options.get('timeout_reason')) ?? undefined;
}
return undefined;
}
function readNonBlankString(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function findChange(changes: GuildAuditLogChange | null, key: string): AuditLogChange | undefined {
return changes?.find((change) => change.key === key);
}
function scrubSensitiveChanges(changes: GuildAuditLogChange | null | undefined): GuildAuditLogChange | undefined {
if (!changes) {
return undefined;
}
const scrubbed = changes.filter((change) => change.key !== 'ip');
return scrubbed.length > 0 ? scrubbed : undefined;
}
function buildAuditLogOptions(options: Map<string, string>): AuditLogOptions | undefined {
if (!options.size) {
return undefined;
}
const mapped: AuditLogOptions = {};
for (const [key, value] of options) {
switch (key) {
case 'channel_id':
mapped.channel_id = value;
break;
case 'count':
assignNumericOption(mapped, 'count', value);
break;
case 'delete_member_days':
mapped.delete_member_days = value;
break;
case 'delete_message_days':
if (!mapped.delete_member_days) {
mapped.delete_member_days = value;
}
break;
case 'delete_message_seconds':
assignNumericOption(mapped, 'delete_message_seconds', value);
break;
case 'id':
mapped.id = value;
break;
case 'integration_type':
assignNumericOption(mapped, 'integration_type', value);
break;
case 'message_id':
mapped.message_id = value;
break;
case 'members_removed':
assignNumericOption(mapped, 'members_removed', value);
break;
case 'role_name':
mapped.role_name = value;
break;
case 'type':
assignNumericOption(mapped, 'type', value);
break;
case 'inviter_id':
mapped.inviter_id = value;
break;
case 'max_age':
assignNumericOption(mapped, 'max_age', value);
break;
case 'max_uses':
assignNumericOption(mapped, 'max_uses', value);
break;
case 'uses':
assignNumericOption(mapped, 'uses', value);
break;
case 'temporary':
mapped.temporary = parseBooleanOption(value);
break;
default:
break;
}
}
return Object.keys(mapped).length === 0 ? undefined : mapped;
}
function parseBooleanOption(value: string): boolean {
return value === 'true' || value === '1';
}
function assignNumericOption(target: AuditLogOptions, key: NumericAuditLogOptionKey, value: string): void {
const parsed = Number(value);
if (!Number.isNaN(parsed)) {
target[key] = parsed;
}
}
function parseUserId(value: unknown): UserID | null {
if (typeof value !== 'string' || !SNOWFLAKE_PATTERN.test(value)) {
return null;
}
const id = BigInt(value);
return isValidSnowflake(id) ? createUserID(id) : null;
}
@@ -1,7 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ChannelID, GuildID, RoleID, UserID} from '@app/api/BrandedTypes';
import {type ChannelID, createRoleID, type GuildID, type RoleID, type UserID} from '@app/api/BrandedTypes';
import type {GuildAuditLogRow} from '@app/api/database/types/GuildTypes';
import {isNoopGuildAuditLog, mapGuildAuditLogEntry} from '@app/api/guild/GuildAuditLogEntryMapper';
import type {AuditLogChange, GuildAuditLogChange} from '@app/api/guild/GuildAuditLogTypes';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
@@ -37,16 +38,6 @@ interface CreateGuildAuditLogParams {
createdAt?: Date;
}
interface GuildAuditLogDispatchEntry {
id: string;
action_type: number;
user_id: string;
target_id: string | null;
reason?: string;
options?: Record<string, string>;
changes?: GuildAuditLogChange;
}
function normalizeAuditLogMetadata(metadata?: CreateGuildAuditLogParams['metadata']): Map<string, string> {
if (!metadata) {
return new Map();
@@ -251,17 +242,37 @@ export class GuildAuditLogService {
: before && !after
? AuditLogActionType.CHANNEL_OVERWRITE_DELETE
: AuditLogActionType.CHANNEL_OVERWRITE_UPDATE;
const builder = this.createBuilder(params.guildId, params.userId)
.withAction(action, targetId.toString())
.withReason(params.reason ?? null)
.withMetadata({
let roleName: string | null = null;
if (overwriteType === 0 && targetId.toString() !== params.guildId.toString()) {
try {
const role = await this.guildRepository.getRole(createRoleID(BigInt(targetId)), params.guildId);
roleName = role?.name ?? null;
} catch (error) {
Logger.warn(
{
error,
guildId: params.guildId.toString(),
roleId: targetId.toString(),
},
'Failed to resolve role name for guild audit log',
);
}
}
try {
const metadata: Record<string, string> = {
id: targetId.toString(),
type: overwriteType.toString(),
channel_id: params.channelId.toString(),
})
.withChanges(changes);
try {
await builder.commit();
};
if (roleName !== null) {
metadata['role_name'] = roleName;
}
await this.createBuilder(params.guildId, params.userId)
.withAction(action, targetId.toString())
.withReason(params.reason ?? null)
.withMetadata(metadata)
.withChanges(changes)
.commit();
} catch (error) {
Logger.error(
{
@@ -356,41 +367,12 @@ export class GuildAuditLogService {
return JSON.stringify(a) === JSON.stringify(b);
}
private mapDispatchOptions(options: Map<string, string>): Record<string, string> | undefined {
if (options.size === 0) {
return undefined;
}
return Object.fromEntries(options);
}
private scrubSensitiveChanges(changes: GuildAuditLogChange | null): GuildAuditLogChange | undefined {
if (!changes) {
return undefined;
}
const scrubbedChanges = changes.filter((change) => change.key !== 'ip');
return scrubbedChanges.length > 0 ? scrubbedChanges : undefined;
}
private buildDispatchEntry(log: GuildAuditLog): GuildAuditLogDispatchEntry {
const options = this.mapDispatchOptions(log.options);
const changes = this.scrubSensitiveChanges(log.changes);
return {
id: log.logId.toString(),
action_type: log.actionType,
user_id: log.userId.toString(),
target_id: log.targetId,
reason: log.reason ?? undefined,
options,
changes,
};
}
private async dispatchAuditLogEntryCreate(log: GuildAuditLog): Promise<void> {
try {
await this.gatewayService.dispatchGuild({
guildId: log.guildId,
event: 'GUILD_AUDIT_LOG_ENTRY_CREATE',
data: this.buildDispatchEntry(log),
data: {...mapGuildAuditLogEntry(log), guild_id: log.guildId.toString()},
});
} catch (error) {
Logger.error(
@@ -459,10 +441,13 @@ class GuildAuditLogBuilder {
return this;
}
async commit(): Promise<GuildAuditLog> {
async commit(): Promise<GuildAuditLog | null> {
if (this.params.actionType === undefined) {
throw new Error('Audit log action type must be set before committing');
}
if (isNoopGuildAuditLog(this.params.actionType, this.params.changes)) {
return null;
}
return this.service.createLog({
guildId: this.params.guildId!,
userId: this.params.userId!,
+16 -5
View File
@@ -15,11 +15,13 @@ import type {GuildMember} from '@app/api/models/GuildMember';
import type {GuildRole} from '@app/api/models/GuildRole';
import type {GuildSticker} from '@app/api/models/GuildSticker';
import {getCachedUserPartialResponse, getCachedUserPartialResponses} from '@app/api/user/UserCacheHelpers';
import type {
GuildEmojiResponse,
GuildEmojiWithUserResponse,
GuildStickerResponse,
GuildStickerWithUserResponse,
import {
GUILD_EXPRESSION_SOURCE_BADGE_FEATURES,
type GuildEmojiResponse,
type GuildEmojiWithUserResponse,
type GuildExpressionSourceGuildResponse,
type GuildStickerResponse,
type GuildStickerWithUserResponse,
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import type {GuildBanResponse, GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildPartialResponse, GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
@@ -50,6 +52,15 @@ export function mapGuildToPartialResponse(guild: Guild): GuildPartialResponse {
};
}
export function mapGuildToExpressionSourceGuildResponse(guild: Guild): GuildExpressionSourceGuildResponse {
return {
id: guild.id.toString(),
name: guild.name,
icon: stripGuildIconForFeatures(guild.iconHash, guild.features),
features: GUILD_EXPRESSION_SOURCE_BADGE_FEATURES.filter((feature) => guild.features.has(feature)),
};
}
export function mapGuildToGuildResponse(
guild: Guild,
options?: {
@@ -137,27 +137,14 @@ export class GuildMemberService {
void this.searchIndexService.updateMember(updatedMember, targetUser, searchIndexOptions);
}
}
const timeoutMetadata = (() => {
if (data.communication_disabled_until === undefined) {
return undefined;
}
const metadata: Record<string, string> = {};
if (data.communication_disabled_until !== null) {
metadata['communication_disabled_until'] = data.communication_disabled_until;
}
const trimmedReason = data.timeout_reason?.trim();
if (trimmedReason) {
metadata['timeout_reason'] = trimmedReason;
}
return Object.keys(metadata).length > 0 ? metadata : undefined;
})();
await this.auditService.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.MEMBER_UPDATE,
targetId: targetId,
auditLogReason: auditLogReason ?? null,
metadata: timeoutMetadata,
auditLogReason:
auditLogReason ??
(data.communication_disabled_until !== undefined ? data.timeout_reason?.trim() || null : null),
changes: this.guildAuditLogService.computeChanges(
previousSnapshot,
this.auditService.serializeMemberForAudit(updatedMember),
@@ -180,6 +167,7 @@ export class GuildMemberService {
const targetMember = await this.guildRepository.getMember(guildId, targetId);
if (!targetMember) throw new UnknownGuildMemberError();
const previousSnapshot = this.auditService.serializeMemberForAudit(targetMember);
const role = await this.guildRepository.getRole(roleId, guildId);
await this.roleService.addMemberRole(params);
const updatedMember = await this.guildRepository.getMember(guildId, targetId);
if (updatedMember) {
@@ -198,7 +186,7 @@ export class GuildMemberService {
action: AuditLogActionType.MEMBER_ROLE_UPDATE,
targetId: targetId,
auditLogReason: auditLogReason ?? null,
metadata: {role_id: roleId.toString(), action: 'add'},
metadata: role ? {role_name: role.name} : undefined,
changes: this.guildAuditLogService.computeChanges(
previousSnapshot,
this.auditService.serializeMemberForAudit(updatedMember),
@@ -218,6 +206,7 @@ export class GuildMemberService {
const targetMember = await this.guildRepository.getMember(guildId, targetId);
if (!targetMember) throw new UnknownGuildMemberError();
const previousSnapshot = this.auditService.serializeMemberForAudit(targetMember);
const role = await this.guildRepository.getRole(roleId, guildId);
await this.roleService.systemAddMemberRole({targetId, guildId, roleId});
const updatedMember = await this.guildRepository.getMember(guildId, targetId);
if (updatedMember) {
@@ -236,7 +225,7 @@ export class GuildMemberService {
action: AuditLogActionType.MEMBER_ROLE_UPDATE,
targetId: targetId,
auditLogReason: null,
metadata: {role_id: roleId.toString(), action: 'add'},
metadata: role ? {role_name: role.name} : undefined,
changes: this.guildAuditLogService.computeChanges(
previousSnapshot,
this.auditService.serializeMemberForAudit(updatedMember),
@@ -259,6 +248,7 @@ export class GuildMemberService {
const targetMember = await this.guildRepository.getMember(guildId, targetId);
if (!targetMember) throw new UnknownGuildMemberError();
const previousSnapshot = this.auditService.serializeMemberForAudit(targetMember);
const role = await this.guildRepository.getRole(roleId, guildId);
await this.roleService.removeMemberRole(params);
const updatedMember = await this.guildRepository.getMember(guildId, targetId);
if (updatedMember) {
@@ -277,7 +267,7 @@ export class GuildMemberService {
action: AuditLogActionType.MEMBER_ROLE_UPDATE,
targetId: targetId,
auditLogReason: auditLogReason ?? null,
metadata: {role_id: roleId.toString(), action: 'remove'},
metadata: role ? {role_name: role.name} : undefined,
changes: this.guildAuditLogService.computeChanges(
previousSnapshot,
this.auditService.serializeMemberForAudit(updatedMember),
@@ -121,13 +121,15 @@ export class GuildModerationService {
});
if (!skipGuildAuditLog) {
const metadata: Record<string, string> | undefined =
deleteMessageDays !== undefined ? {delete_member_days: deleteMessageDays.toString()} : undefined;
effectiveDeleteMessageSeconds && effectiveDeleteMessageSeconds > 0
? {delete_message_seconds: String(effectiveDeleteMessageSeconds)}
: undefined;
await this.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.MEMBER_BAN_ADD,
targetId: targetId,
auditLogReason: auditLogReason ?? null,
auditLogReason: auditLogReason ?? (reason || null),
metadata,
changes: this.guildAuditLogService.computeChanges(null, this.serializeBanForAudit(ban)),
});
@@ -8,6 +8,7 @@ import {mapGuildRoleToResponse} from '@app/api/guild/GuildModel';
import type {IGuildMemberRepository} from '@app/api/guild/repositories/IGuildMemberRepository';
import type {IGuildRoleRepository} from '@app/api/guild/repositories/IGuildRoleRepository';
import {createGuildMfaEnforcer} from '@app/api/guild/services/GuildMfaEnforcement';
import {computeMovedIds, getMemberListRoleOrderIds} from '@app/api/guild/services/role/RoleOrderAuditUtils';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
import {Logger} from '@app/api/Logger';
@@ -245,6 +246,7 @@ export class GuildRoleService {
action: AuditLogActionType.ROLE_UPDATE,
targetId: roleId,
auditLogReason: auditLogReason ?? null,
metadata: {role_name: updatedRole.name},
changes,
});
return mapGuildRoleToResponse(updatedRole);
@@ -403,7 +405,15 @@ export class GuildRoleService {
}
if (changedRoles.length > 0) {
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
await this.recordRolePositionAuditLogs({
guildId,
userId,
roleMap,
changedRoles,
movedRoleIds: this.computeMovedHoistRoleIds({allRoles, changedRoles, everyoneRoleId}),
auditLogReason,
key: 'hoist_position',
});
}
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
@@ -443,7 +453,19 @@ export class GuildRoleService {
}
if (changedRoles.length > 0) {
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
await this.recordRolePositionAuditLogs({
guildId,
userId,
roleMap,
changedRoles,
movedRoleIds: this.computeMovedHoistRoleIds({
allRoles,
changedRoles,
everyoneRoleId: guildIdToRoleId(guildId),
}),
auditLogReason,
key: 'hoist_position',
});
}
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
@@ -629,10 +651,40 @@ export class GuildRoleService {
});
if (changedRoles.length > 0) {
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
await this.recordRolePositionAuditLogs({
guildId,
userId,
roleMap,
changedRoles,
movedRoleIds: computeMovedIds(
currentOrder.map((role) => role.id),
this.getCurrentRoleOrder(updatedRoles, everyoneRoleId).map((role) => role.id),
),
auditLogReason,
key: 'position',
});
}
}
private computeMovedHoistRoleIds(params: {
allRoles: Array<GuildRole>;
changedRoles: Array<GuildRole>;
everyoneRoleId: RoleID;
}): Set<RoleID> {
const {allRoles, changedRoles, everyoneRoleId} = params;
const changedRoleMap = new Map(changedRoles.map((role) => [role.id, role]));
const updatedRoles = allRoles.map((role) => changedRoleMap.get(role.id) ?? role);
const afterOrderIds = getMemberListRoleOrderIds(updatedRoles, everyoneRoleId);
const movedRoleIds = computeMovedIds(getMemberListRoleOrderIds(allRoles, everyoneRoleId), afterOrderIds);
const memberListRoleIds = new Set(afterOrderIds);
for (const role of changedRoles) {
if (!memberListRoleIds.has(role.id)) {
movedRoleIds.add(role.id);
}
}
return movedRoleIds;
}
private getCurrentRoleOrder(allRoles: Array<GuildRole>, everyoneRoleId: RoleID): Array<GuildRole> {
return allRoles
.filter((role) => role.id !== everyoneRoleId)
@@ -709,10 +761,15 @@ export class GuildRoleService {
userId: UserID;
roleMap: Map<RoleID, GuildRole>;
changedRoles: Array<GuildRole>;
movedRoleIds: ReadonlySet<RoleID>;
auditLogReason?: string | null;
key: 'position' | 'hoist_position';
}): Promise<void> {
const {guildId, userId, roleMap, changedRoles, auditLogReason} = params;
const {guildId, userId, roleMap, changedRoles, movedRoleIds, auditLogReason, key} = params;
for (const role of changedRoles) {
if (!movedRoleIds.has(role.id)) {
continue;
}
const oldRole = roleMap.get(role.id);
await this.recordAuditLog({
guildId,
@@ -720,10 +777,10 @@ export class GuildRoleService {
action: AuditLogActionType.ROLE_UPDATE,
targetId: role.id,
auditLogReason: auditLogReason ?? null,
changes: this.guildAuditLogService.computeChanges(
oldRole ? this.serializeRoleForAudit(oldRole) : null,
this.serializeRoleForAudit(role),
),
metadata: {role_name: role.name},
changes: this.guildAuditLogService
.computeChanges(oldRole ? this.serializeRoleForAudit(oldRole) : null, this.serializeRoleForAudit(role))
.filter((change) => change.key === key),
});
}
}
+15 -136
View File
@@ -2,11 +2,17 @@
import type {ApiContext} from '@app/api/ApiContext';
import type {EmojiID, GuildID, RoleID, StickerID, UserID} from '@app/api/BrandedTypes';
import {createUserID, createWebhookID} from '@app/api/BrandedTypes';
import {createWebhookID} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import type {ChannelService} from '@app/api/channel/services/ChannelService';
import {
collectGuildAuditLogUserIds,
isNoopGuildAuditLog,
mapGuildAuditLogEntry,
type StoredGuildAuditLogEntryResponse,
} from '@app/api/guild/GuildAuditLogEntryMapper';
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import type {GuildAuditLogChange} from '@app/api/guild/GuildAuditLogTypes';
import {mapGuildToExpressionSourceGuildResponse} from '@app/api/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import {GuildChannelService} from '@app/api/guild/services/GuildChannelService';
import {GuildContentService} from '@app/api/guild/services/GuildContentService';
@@ -41,9 +47,7 @@ import {UnknownGuildEmojiError} from '@fluxer/errors/src/domains/guild/UnknownGu
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {UnknownGuildStickerError} from '@fluxer/errors/src/domains/guild/UnknownGuildStickerError';
import type {
AuditLogOptions,
AuditLogWebhookResponse,
GuildAuditLogEntryResponse,
GuildAuditLogListResponse,
} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas';
import type {
@@ -55,10 +59,6 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
interface StoredGuildAuditLogEntryResponse extends Omit<GuildAuditLogEntryResponse, 'changes'> {
changes?: GuildAuditLogChange;
}
interface StoredAuditLogWebhookResponse extends Omit<AuditLogWebhookResponse, 'type'> {
type: number;
}
@@ -260,6 +260,7 @@ export class GuildService {
name: emoji.name,
animated: emoji.isAnimated,
allow_cloning: guild.features.has(GuildFeatures.CLONE_EMOJI_ENABLED),
guild: mapGuildToExpressionSourceGuildResponse(guild),
};
}
@@ -273,6 +274,7 @@ export class GuildService {
name: sticker.name,
animated: sticker.animated,
allow_cloning: guild.features.has(GuildFeatures.CLONE_STICKER_ENABLED),
guild: mapGuildToExpressionSourceGuildResponse(guild),
};
}
@@ -338,13 +340,13 @@ export class GuildService {
if (shouldBatch) {
const batchResult = await this.guildAuditLogService.batchConsecutiveMessageDeleteLogs(guildId, logs);
for (const log of batchResult.processedLogs) {
if (processedLogs.length < effectiveLimit) {
if (processedLogs.length < effectiveLimit && !isNoopGuildAuditLog(log.actionType, log.changes)) {
processedLogs.push(log);
}
}
} else {
for (const log of logs) {
if (processedLogs.length < effectiveLimit) {
if (processedLogs.length < effectiveLimit && !isNoopGuildAuditLog(log.actionType, log.changes)) {
processedLogs.push(log);
}
}
@@ -362,10 +364,8 @@ export class GuildService {
processedLogs = processedLogs.slice(0, effectiveLimit);
const userIdSet = new Set<UserID>();
for (const log of processedLogs) {
userIdSet.add(log.userId);
const targetUserId = this.getAuditLogTargetUserId(log);
if (targetUserId) {
userIdSet.add(targetUserId);
for (const referencedUserId of collectGuildAuditLogUserIds(log)) {
userIdSet.add(referencedUserId);
}
}
const [userPartials, webhookRecords] = await Promise.all([
@@ -376,7 +376,7 @@ export class GuildService {
}),
this.loadAuditLogWebhooks(processedLogs),
]);
const entries = processedLogs.map((log) => this.mapAuditLogToEntry(log));
const entries = processedLogs.map((log) => mapGuildAuditLogEntry(log));
const users = Array.from(userPartials.values());
const webhooks = this.buildAuditLogWebhookResponses(webhookRecords.webhooks);
return {
@@ -386,102 +386,6 @@ export class GuildService {
};
}
private mapAuditLogToEntry(log: GuildAuditLog): StoredGuildAuditLogEntryResponse {
return {
id: log.logId.toString(),
action_type: log.actionType,
user_id: log.userId.toString(),
target_id: log.targetId,
reason: log.reason ?? undefined,
options: this.buildAuditLogOptions(log.options),
changes: this.scrubSensitiveChanges(log.changes),
};
}
private scrubSensitiveChanges(changes: GuildAuditLogChange | null | undefined): GuildAuditLogChange | undefined {
if (!changes) {
return undefined;
}
const scrubbed = changes.filter((change) => change.key !== 'ip');
return scrubbed.length > 0 ? scrubbed : undefined;
}
private buildAuditLogOptions(options: Map<string, string>): AuditLogOptions | undefined {
if (!options.size) {
return undefined;
}
const mapped: AuditLogOptions = {};
for (const [key, value] of options) {
switch (key) {
case 'channel_id':
mapped.channel_id = value;
break;
case 'count':
this.assignNumericOption(mapped, 'count', value);
break;
case 'delete_member_days':
mapped.delete_member_days = value;
break;
case 'delete_message_days':
if (!mapped.delete_member_days) {
mapped.delete_member_days = value;
}
break;
case 'id':
mapped.id = value;
break;
case 'integration_type':
this.assignNumericOption(mapped, 'integration_type', value);
break;
case 'message_id':
mapped.message_id = value;
break;
case 'members_removed':
this.assignNumericOption(mapped, 'members_removed', value);
break;
case 'role_name':
mapped.role_name = value;
break;
case 'type':
this.assignNumericOption(mapped, 'type', value);
break;
case 'inviter_id':
mapped.inviter_id = value;
break;
case 'max_age':
this.assignNumericOption(mapped, 'max_age', value);
break;
case 'max_uses':
this.assignNumericOption(mapped, 'max_uses', value);
break;
case 'uses':
this.assignNumericOption(mapped, 'uses', value);
break;
case 'temporary':
mapped.temporary = this.parseBooleanOption(value);
break;
default:
break;
}
}
return Object.keys(mapped).length === 0 ? undefined : mapped;
}
private parseBooleanOption(value: string): boolean {
return value === 'true' || value === '1';
}
private assignNumericOption(
target: AuditLogOptions,
key: 'count' | 'integration_type' | 'members_removed' | 'type' | 'max_age' | 'max_uses' | 'uses',
value: string,
): void {
const parsed = Number(value);
if (!Number.isNaN(parsed)) {
target[key] = parsed;
}
}
private async loadAuditLogWebhooks(logs: Array<GuildAuditLog>): Promise<{
webhooks: Array<Webhook>;
}> {
@@ -525,31 +429,6 @@ export class GuildService {
);
}
private getAuditLogTargetUserId(log: GuildAuditLog): UserID | null {
if (!log.targetId || !this.isUserTargetAction(log.actionType)) {
return null;
}
try {
return createUserID(BigInt(log.targetId));
} catch {
return null;
}
}
private isUserTargetAction(actionType: AuditLogActionType): boolean {
return (
actionType === AuditLogActionType.MEMBER_KICK ||
actionType === AuditLogActionType.MEMBER_PRUNE ||
actionType === AuditLogActionType.MEMBER_BAN_ADD ||
actionType === AuditLogActionType.MEMBER_BAN_REMOVE ||
actionType === AuditLogActionType.MEMBER_UPDATE ||
actionType === AuditLogActionType.MEMBER_ROLE_UPDATE ||
actionType === AuditLogActionType.MEMBER_MOVE ||
actionType === AuditLogActionType.MEMBER_DISCONNECT ||
actionType === AuditLogActionType.BOT_ADD
);
}
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new MissingAccessError();
@@ -71,13 +71,18 @@ export class GuildDataHelpers {
computeGuildChanges(
previousSnapshot: Record<string, unknown> | null,
guildOrSnapshot: Guild | Record<string, unknown> | null,
keys?: ReadonlySet<string>,
): GuildAuditLogChange {
const currentSnapshot = guildOrSnapshot
? 'id' in guildOrSnapshot
? this.serializeGuildForAudit(guildOrSnapshot as Guild)
: guildOrSnapshot
: null;
return this.guildAuditLogService.computeChanges(previousSnapshot, currentSnapshot);
const changes = this.guildAuditLogService.computeChanges(previousSnapshot, currentSnapshot);
if (!keys) {
return changes;
}
return changes.filter((change) => keys.has(change.key));
}
async dispatchGuildUpdate(guild: Guild): Promise<void> {
@@ -122,6 +122,34 @@ const USER_TOGGLEABLE_GUILD_FEATURES: ReadonlySet<string> = new Set([
GuildFeatures.HIDE_OWNER_CROWN,
]);
const SUPPORTED_SYSTEM_CHANNEL_FLAGS = SystemChannelFlags.SUPPRESS_JOIN_NOTIFICATIONS;
const GUILD_SETTINGS_AUDIT_KEYS: ReadonlySet<string> = new Set([
'name',
'icon_hash',
'banner_hash',
'banner_width',
'banner_height',
'splash_hash',
'splash_width',
'splash_height',
'splash_card_alignment',
'embed_splash_hash',
'embed_splash_width',
'embed_splash_height',
'features',
'verification_level',
'mfa_level',
'nsfw_level',
'nsfw',
'content_warning_level',
'content_warning_text',
'explicit_content_filter',
'default_message_notifications',
'system_channel_id',
'system_channel_flags',
'afk_channel_id',
'afk_timeout',
'message_history_cutoff',
]);
function setsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
if (a === b) return true;
@@ -607,7 +635,7 @@ export class GuildOperationsService {
Logger.error({guildId: updatedGuild.id, error}, 'Failed to update guild in search');
});
}
const auditLogChanges = this.helpers.computeGuildChanges(previousSnapshot, updatedGuild);
const auditLogChanges = this.helpers.computeGuildChanges(previousSnapshot, updatedGuild, GUILD_SETTINGS_AUDIT_KEYS);
if (auditLogChanges.length > 0) {
await this.helpers.recordAuditLog({
guildId,
@@ -615,7 +643,6 @@ export class GuildOperationsService {
action: AuditLogActionType.GUILD_UPDATE,
targetId: guildId,
auditLogReason: auditLogReason ?? null,
metadata: {name: updatedGuild.name},
changes: auditLogChanges,
});
}
@@ -17,6 +17,8 @@ import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildEr
import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
const OWNERSHIP_AUDIT_KEYS: ReadonlySet<string> = new Set(['owner_id']);
export class GuildOwnershipService {
constructor(
private readonly guildRepository: IGuildRepositoryAggregate,
@@ -64,8 +66,7 @@ export class GuildOwnershipService {
action: AuditLogActionType.GUILD_UPDATE,
targetId: guildId,
auditLogReason: auditLogReason ?? null,
metadata: {new_owner_id: newOwnerId.toString()},
changes: this.helpers.computeGuildChanges(previousSnapshot, updatedGuild),
changes: this.helpers.computeGuildChanges(previousSnapshot, updatedGuild, OWNERSHIP_AUDIT_KEYS),
});
return mapGuildToGuildResponse(updatedGuild);
}
@@ -19,6 +19,8 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import type {GuildVanityURLResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
const VANITY_AUDIT_KEYS: ReadonlySet<string> = new Set(['vanity_url_code']);
export class GuildVanityService {
constructor(
private readonly guildRepository: IGuildRepositoryAggregate,
@@ -83,8 +85,7 @@ export class GuildVanityService {
action: AuditLogActionType.GUILD_UPDATE,
targetId: guildId,
auditLogReason: auditLogReason ?? null,
metadata: {vanity_url_code: ''},
changes: this.helpers.computeGuildChanges(previousSnapshot, updatedGuild),
changes: this.helpers.computeGuildChanges(previousSnapshot, updatedGuild, VANITY_AUDIT_KEYS),
});
return {code: null};
}
@@ -122,8 +123,7 @@ export class GuildVanityService {
action: AuditLogActionType.GUILD_UPDATE,
targetId: guildId,
auditLogReason: auditLogReason ?? null,
metadata: {vanity_url_code: code},
changes: this.helpers.computeGuildChanges(previousSnapshot, updatedGuild),
changes: this.helpers.computeGuildChanges(previousSnapshot, updatedGuild, VANITY_AUDIT_KEYS),
});
return {code};
}
@@ -177,6 +177,13 @@ export class GuildMemberOperationsService {
connectionId: string | null;
auditLogReason?: string | null;
}): Promise<void> {
if (
params.previousChannelId !== null &&
params.newChannelId !== null &&
params.previousChannelId === params.newChannelId.toString()
) {
return;
}
const action = params.newChannelId === null ? AuditLogActionType.MEMBER_DISCONNECT : AuditLogActionType.MEMBER_MOVE;
const previousSnapshot = params.previousChannelId !== null ? {channel_id: params.previousChannelId} : null;
const nextSnapshot = params.newChannelId !== null ? {channel_id: params.newChannelId.toString()} : null;
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {RoleID} from '@app/api/BrandedTypes';
import type {GuildRole} from '@app/api/models/GuildRole';
export function computeMovedIds<T extends string | bigint>(before: ReadonlyArray<T>, after: ReadonlyArray<T>): Set<T> {
const width = after.length + 1;
const lengths = new Uint32Array((before.length + 1) * width);
const lengthAt = (i: number, j: number): number => lengths[i * width + j] ?? 0;
for (let i = before.length - 1; i >= 0; i--) {
for (let j = after.length - 1; j >= 0; j--) {
lengths[i * width + j] =
before[i] === after[j] ? lengthAt(i + 1, j + 1) + 1 : Math.max(lengthAt(i + 1, j), lengthAt(i, j + 1));
}
}
const keptIndices = new Set<number>();
let i = 0;
let j = 0;
while (i < before.length && j < after.length) {
if (before[i] === after[j]) {
keptIndices.add(j);
i++;
j++;
} else if (lengthAt(i + 1, j) >= lengthAt(i, j + 1)) {
i++;
} else {
j++;
}
}
const movedIds = new Set<T>();
for (const [index, id] of after.entries()) {
if (!keptIndices.has(index)) {
movedIds.add(id);
}
}
return movedIds;
}
export function getMemberListRoleOrderIds(roles: ReadonlyArray<GuildRole>, everyoneRoleId: RoleID): Array<RoleID> {
return roles
.filter((role) => role.isHoisted && role.id !== everyoneRoleId)
.sort((a, b) => b.effectiveHoistPosition - a.effectiveHoistPosition || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
.map((role) => role.id);
}
@@ -0,0 +1,128 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createGuildID, createRoleID, type RoleID} from '@app/api/BrandedTypes';
import type {GuildRoleRow} from '@app/api/database/types/GuildTypes';
import {computeMovedIds, getMemberListRoleOrderIds} from '@app/api/guild/services/role/RoleOrderAuditUtils';
import {GuildRole} from '@app/api/models/GuildRole';
import {describe, expect, test} from 'vitest';
const GUILD_ID = createGuildID(100n);
const EVERYONE_ROLE_ID = createRoleID(100n);
function createRole(overrides: Partial<GuildRoleRow> & Pick<GuildRoleRow, 'role_id'>): GuildRole {
return new GuildRole({
guild_id: GUILD_ID,
name: 'Role',
permissions: 0n,
position: 1,
hoist_position: null,
color: 0,
icon_hash: null,
unicode_emoji: null,
hoist: true,
mentionable: false,
version: 1,
...overrides,
});
}
describe('computeMovedIds', () => {
test('drag up reports only the dragged id', () => {
expect(computeMovedIds(['a', 'b', 'c', 'd'], ['d', 'a', 'b', 'c'])).toEqual(new Set(['d']));
});
test('drag down reports only the dragged id', () => {
expect(computeMovedIds(['a', 'b', 'c', 'd'], ['b', 'c', 'd', 'a'])).toEqual(new Set(['a']));
});
test('drag into the middle reports only the dragged id', () => {
expect(computeMovedIds(['a', 'b', 'c', 'd', 'e'], ['a', 'd', 'b', 'c', 'e'])).toEqual(new Set(['d']));
});
test('neighbour swap keeps the id that comes first after the swap', () => {
expect(computeMovedIds(['a', 'b', 'c'], ['b', 'a', 'c'])).toEqual(new Set(['a']));
});
test('no change reports nothing', () => {
expect(computeMovedIds(['a', 'b', 'c'], ['a', 'b', 'c'])).toEqual(new Set());
});
test('empty sequences report nothing', () => {
expect(computeMovedIds([], [])).toEqual(new Set());
});
test('full reverse keeps only the first id after the reverse', () => {
expect(computeMovedIds(['a', 'b', 'c', 'd'], ['d', 'c', 'b', 'a'])).toEqual(new Set(['c', 'b', 'a']));
});
test('ties between equally long subsequences go to the earliest index in after', () => {
expect(computeMovedIds(['x', 'a', 'b'], ['a', 'x', 'b'])).toEqual(new Set(['x']));
expect(computeMovedIds(['a', 'x', 'b'], ['x', 'a', 'b'])).toEqual(new Set(['a']));
expect(computeMovedIds(['a', 'b', 'c', 'd'], ['c', 'd', 'a', 'b'])).toEqual(new Set(['a', 'b']));
});
test('repeated calls return the same ids in after order', () => {
const before = ['a', 'b', 'c', 'd', 'e', 'f'];
const after = ['f', 'd', 'a', 'e', 'b', 'c'];
const first = [...computeMovedIds(before, after)];
const second = [...computeMovedIds(before, after)];
expect(first).toEqual(second);
expect(first).toEqual(after.filter((id) => first.includes(id)));
});
test('ids only present in after are reported as moved', () => {
expect(computeMovedIds(['a', 'b'], ['a', 'n', 'b'])).toEqual(new Set(['n']));
});
test('bigint ids compare by value', () => {
const before = [createRoleID(1n), createRoleID(2n), createRoleID(3n)];
const after = [createRoleID(3n), createRoleID(1n), createRoleID(2n)];
const moved = computeMovedIds<RoleID>(before, after);
expect(moved).toEqual(new Set([3n]));
expect(moved.has(createRoleID(3n))).toBe(true);
});
});
describe('getMemberListRoleOrderIds', () => {
test('orders hoisted roles like fluxer_gateway guild_member_list_groups.erl:20-45 with the GuildRolesTab.tsx:133-141 id tie-break', () => {
const roles = [
createRole({role_id: EVERYONE_ROLE_ID, position: 0}),
createRole({role_id: createRoleID(201n), position: 5}),
createRole({role_id: createRoleID(202n), position: 4, hoist_position: 9}),
createRole({role_id: createRoleID(203n), position: 3, hoist: false}),
createRole({role_id: createRoleID(205n), position: 2}),
createRole({role_id: createRoleID(204n), position: 1, hoist_position: 2}),
];
expect(getMemberListRoleOrderIds(roles, EVERYONE_ROLE_ID)).toEqual([202n, 201n, 204n, 205n]);
});
test('does not reorder the input array', () => {
const roles = [
createRole({role_id: createRoleID(201n), position: 1}),
createRole({role_id: createRoleID(202n), position: 2}),
];
getMemberListRoleOrderIds(roles, EVERYONE_ROLE_ID);
expect(roles.map((role) => role.id)).toEqual([201n, 202n]);
});
test('a first custom hoist order that moves one role reports only that role', () => {
const before = [
createRole({role_id: createRoleID(201n), position: 3}),
createRole({role_id: createRoleID(202n), position: 2}),
createRole({role_id: createRoleID(203n), position: 1}),
createRole({role_id: createRoleID(204n), position: 4, hoist: false}),
];
const after = [
createRole({role_id: createRoleID(201n), position: 3, hoist_position: 2}),
createRole({role_id: createRoleID(202n), position: 2, hoist_position: 1}),
createRole({role_id: createRoleID(203n), position: 1, hoist_position: 3}),
createRole({role_id: createRoleID(204n), position: 4, hoist: false}),
];
expect(
computeMovedIds(
getMemberListRoleOrderIds(before, EVERYONE_ROLE_ID),
getMemberListRoleOrderIds(after, EVERYONE_ROLE_ID),
),
).toEqual(new Set([203n]));
});
});
@@ -0,0 +1,266 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createChannel, createRole, setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface AuditLogChange {
key: string;
old_value?: unknown;
new_value?: unknown;
}
interface AuditLogOptions {
channel_id?: string;
id?: string;
role_name?: string;
type?: number;
}
interface AuditLogEntry {
id: string;
action_type: number;
user_id: string | null;
target_id: string | null;
reason?: string;
options?: AuditLogOptions;
changes?: Array<AuditLogChange>;
}
interface AuditLogResponse {
audit_log_entries: Array<AuditLogEntry>;
users: Array<{
id: string;
}>;
}
async function fetchAuditLog(
harness: ApiTestHarness,
token: string,
guildId: string,
actionType: AuditLogActionType,
): Promise<AuditLogResponse> {
return createBuilder<AuditLogResponse>(harness, token)
.get(`/guilds/${guildId}/audit-logs?action_type=${actionType}`)
.expect(HTTP_STATUS.OK)
.execute();
}
function requireEntry(entries: Array<AuditLogEntry>, predicate: (entry: AuditLogEntry) => boolean): AuditLogEntry {
const entry = entries.find(predicate);
if (!entry) {
throw new Error('Expected audit log entry was not recorded');
}
return entry;
}
function changeKeys(entry: AuditLogEntry): Array<string> {
return (entry.changes ?? []).map((change) => change.key);
}
describe('Guild audit log channel writers', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('records a voice channel created under a category and then its overwrite with the header reason', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const role = await createRole(harness, owner.token, guild.id, {name: 'Speakers'});
const category = await createChannel(harness, owner.token, guild.id, 'Voice', ChannelTypes.GUILD_CATEGORY);
const reason = 'Set up the lounge';
const voice = await createBuilder<ChannelResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/channels`)
.header('X-Audit-Log-Reason', reason)
.body({
name: 'Lounge',
type: ChannelTypes.GUILD_VOICE,
parent_id: category.id,
permission_overwrites: [{id: role.id, type: 0, allow: Permissions.CONNECT.toString(), deny: '0'}],
})
.expect(HTTP_STATUS.OK)
.execute();
const createLog = await fetchAuditLog(harness, owner.token, guild.id, AuditLogActionType.CHANNEL_CREATE);
const createEntry = requireEntry(createLog.audit_log_entries, (entry) => entry.target_id === voice.id);
expect(createEntry.reason).toBe(reason);
expect(createEntry.changes?.find((change) => change.key === 'parent_id')?.new_value).toBe(category.id);
expect(changeKeys(createEntry)).not.toContain('permission_overwrite_count');
const overwriteLog = await fetchAuditLog(
harness,
owner.token,
guild.id,
AuditLogActionType.CHANNEL_OVERWRITE_CREATE,
);
const overwriteEntry = requireEntry(
overwriteLog.audit_log_entries,
(entry) => entry.target_id === role.id && entry.options?.channel_id === voice.id,
);
expect(overwriteEntry.reason).toBe(reason);
expect(BigInt(overwriteEntry.id) > BigInt(createEntry.id)).toBe(true);
});
test('records the url of a link channel on create and on a url-only update', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const link = await createBuilder<ChannelResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'docs', type: ChannelTypes.GUILD_LINK, url: 'https://fluxer.app/docs'})
.expect(HTTP_STATUS.OK)
.execute();
const createLog = await fetchAuditLog(harness, owner.token, guild.id, AuditLogActionType.CHANNEL_CREATE);
const createEntry = requireEntry(createLog.audit_log_entries, (entry) => entry.target_id === link.id);
expect(createEntry.changes?.find((change) => change.key === 'url')?.new_value).toBe('https://fluxer.app/docs');
await createBuilder<ChannelResponse>(harness, owner.token)
.patch(`/channels/${link.id}`)
.body({url: 'https://fluxer.app/help'})
.expect(HTTP_STATUS.OK)
.execute();
const updateLog = await fetchAuditLog(harness, owner.token, guild.id, AuditLogActionType.CHANNEL_UPDATE);
const updateEntry = requireEntry(updateLog.audit_log_entries, (entry) => entry.target_id === link.id);
expect(updateEntry.options?.type).toBe(ChannelTypes.GUILD_LINK);
expect(updateEntry.changes).toEqual([
{key: 'url', old_value: 'https://fluxer.app/docs', new_value: 'https://fluxer.app/help'},
]);
});
test('records a channel rename with the header reason', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const channel = await createChannel(harness, owner.token, guild.id, 'before-rename');
const reason = 'Clearer name';
await createBuilder<ChannelResponse>(harness, owner.token)
.patch(`/channels/${channel.id}`)
.header('X-Audit-Log-Reason', reason)
.body({name: 'after-rename'})
.expect(HTTP_STATUS.OK)
.execute();
const log = await fetchAuditLog(harness, owner.token, guild.id, AuditLogActionType.CHANNEL_UPDATE);
const entry = requireEntry(log.audit_log_entries, (candidate) => candidate.target_id === channel.id);
expect(entry.user_id).toBe(owner.userId);
expect(entry.reason).toBe(reason);
expect(entry.changes).toEqual([{key: 'name', old_value: 'before-rename', new_value: 'after-rename'}]);
});
test('records a permissions update as overwrite entries with the header reason and no channel update', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const channel = await createChannel(harness, owner.token, guild.id, 'announcements');
const role = await createRole(harness, owner.token, guild.id, {name: 'Posters'});
const reason = 'Restrict posting';
await createBuilder<ChannelResponse>(harness, owner.token)
.patch(`/channels/${channel.id}`)
.header('X-Audit-Log-Reason', reason)
.body({
permission_overwrites: [
{id: role.id, type: 0, allow: Permissions.SEND_MESSAGES.toString(), deny: '0'},
{id: guild.id, type: 0, allow: '0', deny: Permissions.SEND_MESSAGES.toString()},
],
})
.expect(HTTP_STATUS.OK)
.execute();
const updateLog = await fetchAuditLog(harness, owner.token, guild.id, AuditLogActionType.CHANNEL_UPDATE);
expect(updateLog.audit_log_entries.filter((entry) => entry.target_id === channel.id)).toHaveLength(0);
const overwriteLog = await fetchAuditLog(
harness,
owner.token,
guild.id,
AuditLogActionType.CHANNEL_OVERWRITE_CREATE,
);
const channelEntries = overwriteLog.audit_log_entries.filter((entry) => entry.options?.channel_id === channel.id);
expect(channelEntries.map((entry) => entry.target_id).sort()).toEqual([guild.id, role.id].sort());
for (const entry of channelEntries) {
expect(entry.reason).toBe(reason);
}
});
test('records a channel delete with the header reason', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const channel = await createChannel(harness, owner.token, guild.id, 'to-delete');
const reason = 'No longer needed';
await createBuilder(harness, owner.token)
.delete(`/channels/${channel.id}`)
.header('X-Audit-Log-Reason', reason)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const log = await fetchAuditLog(harness, owner.token, guild.id, AuditLogActionType.CHANNEL_DELETE);
const entry = requireEntry(log.audit_log_entries, (candidate) => candidate.target_id === channel.id);
expect(entry.reason).toBe(reason);
expect(entry.options?.type).toBe(ChannelTypes.GUILD_TEXT);
expect(changeKeys(entry)).not.toContain('permission_overwrite_count');
});
test('records overwrite create, update and delete on the permissions route with the reason and role name', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const channel = await createChannel(harness, owner.token, guild.id, 'overrides');
const role = await createRole(harness, owner.token, guild.id, {name: 'Moderators'});
const sendOnly = Permissions.SEND_MESSAGES;
const sendAndAttach = Permissions.SEND_MESSAGES | Permissions.ATTACH_FILES;
const putOverwrite = async (allow: bigint, reason: string) => {
await createBuilder(harness, owner.token)
.put(`/channels/${channel.id}/permissions/${role.id}`)
.header('X-Audit-Log-Reason', reason)
.body({type: 0, allow: allow.toString(), deny: '0'})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
};
await putOverwrite(sendOnly, 'Grant sending');
await putOverwrite(sendAndAttach, 'Grant uploads');
await putOverwrite(sendAndAttach, 'Repeat uploads');
await createBuilder(harness, owner.token)
.delete(`/channels/${channel.id}/permissions/${role.id}`)
.header('X-Audit-Log-Reason', 'Drop override')
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const isRoleOverwrite = (entry: AuditLogEntry) =>
entry.target_id === role.id && entry.options?.channel_id === channel.id;
const createEntries = (
await fetchAuditLog(harness, owner.token, guild.id, AuditLogActionType.CHANNEL_OVERWRITE_CREATE)
).audit_log_entries.filter(isRoleOverwrite);
const updateEntries = (
await fetchAuditLog(harness, owner.token, guild.id, AuditLogActionType.CHANNEL_OVERWRITE_UPDATE)
).audit_log_entries.filter(isRoleOverwrite);
const deleteEntries = (
await fetchAuditLog(harness, owner.token, guild.id, AuditLogActionType.CHANNEL_OVERWRITE_DELETE)
).audit_log_entries.filter(isRoleOverwrite);
expect(createEntries).toHaveLength(1);
expect(updateEntries).toHaveLength(1);
expect(deleteEntries).toHaveLength(1);
expect(createEntries[0]?.reason).toBe('Grant sending');
expect(updateEntries[0]?.reason).toBe('Grant uploads');
expect(deleteEntries[0]?.reason).toBe('Drop override');
expect(updateEntries[0]?.changes).toEqual([
{key: 'allow', old_value: sendOnly.toString(), new_value: sendAndAttach.toString()},
]);
for (const entry of [...createEntries, ...updateEntries, ...deleteEntries]) {
expect(entry.user_id).toBe(owner.userId);
expect(entry.options?.type).toBe(0);
expect(entry.options?.role_name).toBe('Moderators');
}
});
test('lists a member overwrite target in users', async () => {
const {owner, members, guild, channels} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
const channel = channels[0];
await createBuilder(harness, owner.token)
.put(`/channels/${channel.id}/permissions/${member.userId}`)
.body({type: 1, allow: Permissions.ATTACH_FILES.toString(), deny: '0'})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const log = await fetchAuditLog(harness, owner.token, guild.id, AuditLogActionType.CHANNEL_OVERWRITE_CREATE);
const entry = requireEntry(log.audit_log_entries, (candidate) => candidate.target_id === member.userId);
expect(entry.options?.type).toBe(1);
expect(entry.options?.role_name).toBeUndefined();
expect(log.users.map((user) => user.id)).toContain(member.userId);
});
test('writes nothing for a channel update without an effective change', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const channel = await createChannel(harness, owner.token, guild.id, 'steady-name');
await createBuilder<ChannelResponse>(harness, owner.token)
.patch(`/channels/${channel.id}`)
.header('X-Audit-Log-Reason', 'Nothing to change')
.body({name: 'steady-name', topic: null})
.expect(HTTP_STATUS.OK)
.execute();
const log = await fetchAuditLog(harness, owner.token, guild.id, AuditLogActionType.CHANNEL_UPDATE);
expect(log.audit_log_entries.filter((entry) => entry.target_id === channel.id)).toHaveLength(0);
});
});
@@ -0,0 +1,184 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
import {
addMemberRole,
createChannel,
createChannelInvite,
createRole,
deleteInvite,
setupTestGuildWithMembers,
} from '@app/api/guild/tests/GuildTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {createGuildEmoji, createWebhook, deleteWebhook, updateWebhook} from '@app/api/webhook/tests/WebhookTestUtils';
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import type {GuildAuditLogListResponse} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas';
import type {GuildStickerWithUserResponse} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
async function listAuditLogs(
harness: ApiTestHarness,
token: string,
guildId: string,
actionType?: AuditLogActionType,
): Promise<GuildAuditLogListResponse> {
const query = actionType === undefined ? '' : `?action_type=${actionType}`;
return createBuilder<GuildAuditLogListResponse>(harness, token)
.get(`/guilds/${guildId}/audit-logs${query}`)
.expect(HTTP_STATUS.OK)
.execute();
}
async function grantGuildPermissions(
harness: ApiTestHarness,
owner: TestAccount,
guildId: string,
member: TestAccount,
permissions: bigint,
): Promise<void> {
const role = await createRole(harness, owner.token, guildId, {
name: 'Audit moderator',
permissions: (Permissions.VIEW_CHANNEL | permissions).toString(),
});
await addMemberRole(harness, owner.token, guildId, member.userId, role.id);
}
describe('Guild audit log content writers', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('types invite create options', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const channel = await createChannel(harness, owner.token, guild.id, 'invites');
const invite = await createChannelInvite(harness, owner.token, channel.id);
const response = await listAuditLogs(harness, owner.token, guild.id, AuditLogActionType.INVITE_CREATE);
const entry = response.audit_log_entries.find((log) => log.target_id === invite.code);
expect(entry).toBeDefined();
expect(Object.keys(entry?.options ?? {}).sort()).toEqual([
'channel_id',
'inviter_id',
'max_age',
'max_uses',
'temporary',
]);
expect(entry?.options?.channel_id).toBe(channel.id);
expect(entry?.options?.inviter_id).toBe(owner.userId);
expect(typeof entry?.options?.max_age).toBe('number');
expect(typeof entry?.options?.max_uses).toBe('number');
expect(typeof entry?.options?.temporary).toBe('boolean');
});
test('returns the inviter of an invite deleted by a moderator', async () => {
const {owner, members, guild, channels} = await setupTestGuildWithMembers(harness, 1);
const member = members[0]!;
const invite = await createChannelInvite(harness, member.token, channels[0]!.id);
await deleteInvite(harness, owner.token, invite.code);
const response = await listAuditLogs(harness, owner.token, guild.id, AuditLogActionType.INVITE_DELETE);
const entry = response.audit_log_entries.find((log) => log.target_id === invite.code);
expect(entry?.user_id).toBe(owner.userId);
expect(entry?.options?.inviter_id).toBe(member.userId);
expect(response.users.map((user) => user.id)).toContain(member.userId);
});
test('does not record a webhook update that changes nothing', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const channel = await createChannel(harness, owner.token, guild.id, 'hooks');
const webhook = await createWebhook(harness, channel.id, owner.token, 'Audit Hook');
await updateWebhook(harness, webhook.id, owner.token, {name: 'Audit Hook'});
const unchanged = await listAuditLogs(harness, owner.token, guild.id, AuditLogActionType.WEBHOOK_UPDATE);
expect(unchanged.audit_log_entries.filter((log) => log.target_id === webhook.id)).toHaveLength(0);
await updateWebhook(harness, webhook.id, owner.token, {name: 'Renamed Hook'});
const renamed = await listAuditLogs(harness, owner.token, guild.id, AuditLogActionType.WEBHOOK_UPDATE);
const entries = renamed.audit_log_entries.filter((log) => log.target_id === webhook.id);
expect(entries).toHaveLength(1);
expect(entries[0]?.changes).toEqual([{key: 'name', old_value: 'Audit Hook', new_value: 'Renamed Hook'}]);
});
test('returns the creator of a webhook deleted by another moderator', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0]!;
await grantGuildPermissions(harness, owner, guild.id, member, Permissions.MANAGE_WEBHOOKS);
const channel = await createChannel(harness, owner.token, guild.id, 'hooks');
const webhook = await createWebhook(harness, channel.id, owner.token, 'Owner Hook');
await deleteWebhook(harness, webhook.id, member.token);
const response = await listAuditLogs(harness, owner.token, guild.id, AuditLogActionType.WEBHOOK_DELETE);
const entry = response.audit_log_entries.find((log) => log.target_id === webhook.id);
expect(entry?.user_id).toBe(member.userId);
expect(response.users.map((user) => user.id)).toContain(owner.userId);
});
test('does not record an emoji rename to the same name', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const emoji = await createGuildEmoji(harness, owner.token, guild.id, 'audit_emoji');
await createBuilder(harness, owner.token)
.patch(`/guilds/${guild.id}/emojis/${emoji.id}`)
.body({name: 'audit_emoji'})
.expect(HTTP_STATUS.OK)
.execute();
const response = await listAuditLogs(harness, owner.token, guild.id, AuditLogActionType.EMOJI_UPDATE);
expect(response.audit_log_entries.filter((log) => log.target_id === emoji.id)).toHaveLength(0);
});
test('returns the uploader of an emoji deleted by a moderator', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0]!;
await grantGuildPermissions(harness, owner, guild.id, member, Permissions.MANAGE_EXPRESSIONS);
const emoji = await createGuildEmoji(harness, owner.token, guild.id, 'owner_emoji');
await createBuilder(harness, member.token)
.delete(`/guilds/${guild.id}/emojis/${emoji.id}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const response = await listAuditLogs(harness, owner.token, guild.id, AuditLogActionType.EMOJI_DELETE);
const entry = response.audit_log_entries.find((log) => log.target_id === emoji.id);
expect(entry?.user_id).toBe(member.userId);
expect(response.users.map((user) => user.id)).toContain(owner.userId);
});
test('does not record a sticker update that changes nothing', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const sticker = await createBuilder<GuildStickerWithUserResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/stickers`)
.body({name: 'audit_sticker', description: 'audit sticker', tags: [], image: getPngDataUrl()})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.patch(`/guilds/${guild.id}/stickers/${sticker.id}`)
.body({name: 'audit_sticker', description: 'audit sticker', tags: []})
.expect(HTTP_STATUS.OK)
.execute();
const response = await listAuditLogs(harness, owner.token, guild.id, AuditLogActionType.STICKER_UPDATE);
expect(response.audit_log_entries.filter((log) => log.target_id === sticker.id)).toHaveLength(0);
});
test('serves the same entries on the admin route', async () => {
const {owner, members, guild, channels} = await setupTestGuildWithMembers(harness, 1);
const member = members[0]!;
const invite = await createChannelInvite(harness, member.token, channels[0]!.id);
await deleteInvite(harness, owner.token, invite.code);
await createRole(harness, owner.token, guild.id, {
name: 'Admin parity role',
permissions: Permissions.VIEW_CHANNEL.toString(),
});
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'guild:audit_log:view']);
const publicResponse = await listAuditLogs(harness, owner.token, guild.id);
const adminResponse = await createBuilder<GuildAuditLogListResponse>(harness, admin.token)
.get(`/admin/guilds/${guild.id}/audit-logs`)
.expect(HTTP_STATUS.OK)
.execute();
expect(publicResponse.audit_log_entries.length).toBeGreaterThan(0);
expect(adminResponse.audit_log_entries).toEqual(publicResponse.audit_log_entries);
expect(adminResponse.users.map((user) => user.id).sort()).toEqual(
publicResponse.users.map((user) => user.id).sort(),
);
});
});
@@ -0,0 +1,469 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
import {
collectGuildAuditLogUserIds,
GUILD_AUDIT_INTERNAL_CHANGE_KEYS,
isNoopGuildAuditLog,
mapGuildAuditLogEntry,
} from '@app/api/guild/GuildAuditLogEntryMapper';
import type {GuildAuditLogChange} from '@app/api/guild/GuildAuditLogTypes';
import {GuildAuditLog} from '@app/api/models/GuildAuditLog';
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {describe, expect, it} from 'vitest';
const GUILD_ID = createGuildID(1420000000000000000n);
const ACTOR_ID = '1420000000000000001';
const TARGET_ID = '1420000000000000002';
const OTHER_ID = '1420000000000000003';
const ALL_ACTIONS = Object.values(AuditLogActionType).filter(
(value): value is AuditLogActionType => typeof value === 'number',
);
const SKIPPABLE_ACTIONS = [
AuditLogActionType.GUILD_UPDATE,
AuditLogActionType.CHANNEL_UPDATE,
AuditLogActionType.CHANNEL_OVERWRITE_UPDATE,
AuditLogActionType.MEMBER_UPDATE,
AuditLogActionType.MEMBER_ROLE_UPDATE,
AuditLogActionType.MEMBER_MOVE,
AuditLogActionType.ROLE_UPDATE,
AuditLogActionType.WEBHOOK_UPDATE,
AuditLogActionType.EMOJI_UPDATE,
AuditLogActionType.STICKER_UPDATE,
];
const RECORDED_ACTIONS = ALL_ACTIONS.filter((action) => !SKIPPABLE_ACTIONS.includes(action));
const USER_TARGET_ACTIONS = [
AuditLogActionType.MEMBER_KICK,
AuditLogActionType.MEMBER_PRUNE,
AuditLogActionType.MEMBER_BAN_ADD,
AuditLogActionType.MEMBER_BAN_REMOVE,
AuditLogActionType.MEMBER_UPDATE,
AuditLogActionType.MEMBER_ROLE_UPDATE,
AuditLogActionType.MEMBER_MOVE,
AuditLogActionType.MEMBER_DISCONNECT,
AuditLogActionType.BOT_ADD,
];
const OVERWRITE_ACTIONS = [
AuditLogActionType.CHANNEL_OVERWRITE_CREATE,
AuditLogActionType.CHANNEL_OVERWRITE_UPDATE,
AuditLogActionType.CHANNEL_OVERWRITE_DELETE,
];
function makeLog(params: {
actionType: AuditLogActionType;
userId?: string;
targetId?: string | null;
reason?: string | null;
options?: Record<string, string>;
changes?: GuildAuditLogChange | null;
}): GuildAuditLog {
return new GuildAuditLog({
guild_id: GUILD_ID,
log_id: 1420000000000000100n,
user_id: createUserID(BigInt(params.userId ?? ACTOR_ID)),
target_id: params.targetId ?? null,
action_type: params.actionType,
reason: params.reason ?? null,
options: params.options ? new Map(Object.entries(params.options)) : null,
changes: params.changes ? JSON.stringify(params.changes) : null,
});
}
function userIdStrings(log: GuildAuditLog): Array<string> {
return collectGuildAuditLogUserIds(log).map((id) => id.toString());
}
describe('mapGuildAuditLogEntry', () => {
it('keeps the identity fields', () => {
const entry = mapGuildAuditLogEntry(makeLog({actionType: AuditLogActionType.MEMBER_KICK, targetId: TARGET_ID}));
expect(entry.id).toBe('1420000000000000100');
expect(entry.action_type).toBe(AuditLogActionType.MEMBER_KICK);
expect(entry.user_id).toBe(ACTOR_ID);
expect(entry.target_id).toBe(TARGET_ID);
});
it('types every schema option and drops unknown keys', () => {
const entry = mapGuildAuditLogEntry(
makeLog({
actionType: AuditLogActionType.INVITE_DELETE,
options: {
channel_id: OTHER_ID,
count: '3',
delete_member_days: '7',
delete_message_seconds: '3600',
id: TARGET_ID,
integration_type: '1',
message_id: OTHER_ID,
members_removed: '4',
role_name: 'Moderators',
type: '1',
inviter_id: ACTOR_ID,
max_age: '86400',
max_uses: '10',
temporary: 'true',
uses: '2',
name: 'raw name',
new_owner_id: OTHER_ID,
vanity_url_code: 'fluxer',
role_id: OTHER_ID,
action: 'add',
timeout_reason: 'raw reason',
communication_disabled_until: '2026-09-13T00:00:00.000Z',
},
}),
);
expect(entry.options).toEqual({
channel_id: OTHER_ID,
count: 3,
delete_member_days: '7',
delete_message_seconds: 3600,
id: TARGET_ID,
integration_type: 1,
message_id: OTHER_ID,
members_removed: 4,
role_name: 'Moderators',
type: 1,
inviter_id: ACTOR_ID,
max_age: 86400,
max_uses: 10,
temporary: true,
uses: 2,
});
});
it('maps the legacy delete_message_days key onto delete_member_days', () => {
const legacyOnly = mapGuildAuditLogEntry(
makeLog({actionType: AuditLogActionType.MEMBER_BAN_ADD, options: {delete_message_days: '2'}}),
);
expect(legacyOnly.options).toEqual({delete_member_days: '2'});
const legacyFirst = mapGuildAuditLogEntry(
makeLog({
actionType: AuditLogActionType.MEMBER_BAN_ADD,
options: {delete_message_days: '2', delete_member_days: '5'},
}),
);
expect(legacyFirst.options).toEqual({delete_member_days: '5'});
const currentFirst = mapGuildAuditLogEntry(
makeLog({
actionType: AuditLogActionType.MEMBER_BAN_ADD,
options: {delete_member_days: '5', delete_message_days: '2'},
}),
);
expect(currentFirst.options).toEqual({delete_member_days: '5'});
});
it('drops numeric options that do not parse', () => {
const entry = mapGuildAuditLogEntry(
makeLog({
actionType: AuditLogActionType.MEMBER_BAN_ADD,
options: {count: 'many', delete_message_seconds: 'NaN'},
}),
);
expect(entry.options).toBeUndefined();
});
it('returns undefined options when nothing survives', () => {
expect(mapGuildAuditLogEntry(makeLog({actionType: AuditLogActionType.GUILD_UPDATE})).options).toBeUndefined();
expect(
mapGuildAuditLogEntry(makeLog({actionType: AuditLogActionType.GUILD_UPDATE, options: {name: 'Fluxer'}})).options,
).toBeUndefined();
});
it('scrubs ip changes', () => {
const entry = mapGuildAuditLogEntry(
makeLog({
actionType: AuditLogActionType.MEMBER_BAN_ADD,
changes: [
{key: 'ip', new_value: '203.0.113.1'},
{key: 'reason', new_value: 'spam'},
],
}),
);
expect(entry.changes).toEqual([{key: 'reason', new_value: 'spam'}]);
});
it('returns undefined changes for empty and ip-only rows', () => {
expect(mapGuildAuditLogEntry(makeLog({actionType: AuditLogActionType.MEMBER_BAN_ADD, changes: []})).changes).toBe(
undefined,
);
expect(
mapGuildAuditLogEntry(
makeLog({actionType: AuditLogActionType.MEMBER_BAN_ADD, changes: [{key: 'ip', new_value: '203.0.113.1'}]}),
).changes,
).toBeUndefined();
expect(mapGuildAuditLogEntry(makeLog({actionType: AuditLogActionType.MEMBER_KICK})).changes).toBeUndefined();
});
it('keeps an explicit reason trimmed', () => {
const entry = mapGuildAuditLogEntry(
makeLog({actionType: AuditLogActionType.MEMBER_KICK, targetId: TARGET_ID, reason: ' spam '}),
);
expect(entry.reason).toBe('spam');
});
it('returns undefined for a blank reason', () => {
const entry = mapGuildAuditLogEntry(
makeLog({actionType: AuditLogActionType.MEMBER_KICK, targetId: TARGET_ID, reason: ' '}),
);
expect(entry.reason).toBeUndefined();
});
it('falls back to the ban change reason', () => {
const entry = mapGuildAuditLogEntry(
makeLog({
actionType: AuditLogActionType.MEMBER_BAN_ADD,
targetId: TARGET_ID,
changes: [{key: 'reason', new_value: 'body reason'}],
}),
);
expect(entry.reason).toBe('body reason');
});
it('prefers the explicit reason over the ban change reason', () => {
const entry = mapGuildAuditLogEntry(
makeLog({
actionType: AuditLogActionType.MEMBER_BAN_ADD,
targetId: TARGET_ID,
reason: 'header reason',
changes: [{key: 'reason', new_value: 'body reason'}],
}),
);
expect(entry.reason).toBe('header reason');
});
it('ignores a blank ban change reason', () => {
const entry = mapGuildAuditLogEntry(
makeLog({
actionType: AuditLogActionType.MEMBER_BAN_ADD,
targetId: TARGET_ID,
reason: ' ',
changes: [{key: 'reason', new_value: ' '}],
}),
);
expect(entry.reason).toBeUndefined();
});
it('falls back to the timeout_reason option for member updates', () => {
const entry = mapGuildAuditLogEntry(
makeLog({
actionType: AuditLogActionType.MEMBER_UPDATE,
targetId: TARGET_ID,
options: {timeout_reason: 'cool off', communication_disabled_until: '2026-09-13T00:00:00.000Z'},
changes: [{key: 'communication_disabled_until', new_value: '2026-09-13T00:00:00.000Z'}],
}),
);
expect(entry.reason).toBe('cool off');
expect(entry.options).toBeUndefined();
});
it('ignores a blank timeout_reason option', () => {
const entry = mapGuildAuditLogEntry(
makeLog({actionType: AuditLogActionType.MEMBER_UPDATE, targetId: TARGET_ID, options: {timeout_reason: ' '}}),
);
expect(entry.reason).toBeUndefined();
});
it('never falls back for other actions', () => {
const entry = mapGuildAuditLogEntry(
makeLog({
actionType: AuditLogActionType.MEMBER_KICK,
targetId: TARGET_ID,
options: {timeout_reason: 'cool off'},
changes: [{key: 'reason', new_value: 'body reason'}],
}),
);
expect(entry.reason).toBeUndefined();
});
it.each(ALL_ACTIONS)('maps action %i without throwing', (actionType) => {
const log = makeLog({
actionType,
targetId: TARGET_ID,
options: {channel_id: OTHER_ID, type: '1', inviter_id: OTHER_ID},
changes: [
{key: 'owner_id', new_value: OTHER_ID},
{key: 'moderator_id', old_value: OTHER_ID},
{key: 'creator_id', old_value: OTHER_ID},
],
});
expect(() => mapGuildAuditLogEntry(log)).not.toThrow();
expect(() => collectGuildAuditLogUserIds(log)).not.toThrow();
});
});
describe('collectGuildAuditLogUserIds', () => {
it('includes the actor and never a channel target', () => {
const log = makeLog({actionType: AuditLogActionType.CHANNEL_CREATE, targetId: TARGET_ID});
expect(userIdStrings(log)).toEqual([ACTOR_ID]);
});
it.each(USER_TARGET_ACTIONS)('includes the target of user action %i', (actionType) => {
const log = makeLog({actionType, targetId: TARGET_ID});
expect(userIdStrings(log)).toEqual([ACTOR_ID, TARGET_ID]);
});
it('includes the new owner of a guild update', () => {
const log = makeLog({
actionType: AuditLogActionType.GUILD_UPDATE,
targetId: GUILD_ID.toString(),
changes: [{key: 'owner_id', old_value: TARGET_ID, new_value: OTHER_ID}],
});
expect(userIdStrings(log)).toEqual([ACTOR_ID, OTHER_ID]);
});
it('includes the original moderator of an unban', () => {
const log = makeLog({
actionType: AuditLogActionType.MEMBER_BAN_REMOVE,
targetId: TARGET_ID,
changes: [{key: 'moderator_id', old_value: OTHER_ID}],
});
expect(userIdStrings(log)).toEqual([ACTOR_ID, TARGET_ID, OTHER_ID]);
});
it('includes the inviter of a deleted invite', () => {
const log = makeLog({
actionType: AuditLogActionType.INVITE_DELETE,
targetId: 'abcdef',
options: {inviter_id: OTHER_ID},
});
expect(userIdStrings(log)).toEqual([ACTOR_ID, OTHER_ID]);
});
it('does not include the inviter of a created invite', () => {
const log = makeLog({
actionType: AuditLogActionType.INVITE_CREATE,
targetId: 'abcdef',
options: {inviter_id: OTHER_ID},
});
expect(userIdStrings(log)).toEqual([ACTOR_ID]);
});
it.each([
AuditLogActionType.WEBHOOK_DELETE,
AuditLogActionType.EMOJI_DELETE,
AuditLogActionType.STICKER_DELETE,
])('includes the creator of deleted content for action %i', (actionType) => {
const log = makeLog({
actionType,
targetId: TARGET_ID,
changes: [
{key: 'name', old_value: 'blob'},
{key: 'creator_id', old_value: OTHER_ID},
],
});
expect(userIdStrings(log)).toEqual([ACTOR_ID, OTHER_ID]);
});
it.each(OVERWRITE_ACTIONS)('includes member overwrite targets for action %i', (actionType) => {
const memberLog = makeLog({actionType, targetId: TARGET_ID, options: {type: '1', channel_id: OTHER_ID}});
expect(userIdStrings(memberLog)).toEqual([ACTOR_ID, TARGET_ID]);
const roleLog = makeLog({actionType, targetId: TARGET_ID, options: {type: '0', channel_id: OTHER_ID}});
expect(userIdStrings(roleLog)).toEqual([ACTOR_ID]);
});
it('skips values that are not snowflakes', () => {
expect(userIdStrings(makeLog({actionType: AuditLogActionType.MEMBER_KICK, targetId: 'not-a-snowflake'}))).toEqual([
ACTOR_ID,
]);
expect(userIdStrings(makeLog({actionType: AuditLogActionType.MEMBER_KICK, targetId: '-1'}))).toEqual([ACTOR_ID]);
expect(
userIdStrings(makeLog({actionType: AuditLogActionType.MEMBER_KICK, targetId: '99999999999999999999'})),
).toEqual([ACTOR_ID]);
const numericOwner = makeLog({
actionType: AuditLogActionType.GUILD_UPDATE,
changes: [{key: 'owner_id', new_value: 1420}],
});
expect(userIdStrings(numericOwner)).toEqual([ACTOR_ID]);
const missingCreator = makeLog({
actionType: AuditLogActionType.EMOJI_DELETE,
changes: [{key: 'creator_id', old_value: null}],
});
expect(userIdStrings(missingCreator)).toEqual([ACTOR_ID]);
});
it('collapses duplicates', () => {
const selfUpdate = makeLog({actionType: AuditLogActionType.MEMBER_UPDATE, targetId: ACTOR_ID});
expect(userIdStrings(selfUpdate)).toEqual([ACTOR_ID]);
const unbanOfModerator = makeLog({
actionType: AuditLogActionType.MEMBER_BAN_REMOVE,
targetId: TARGET_ID,
changes: [{key: 'moderator_id', old_value: TARGET_ID}],
});
expect(userIdStrings(unbanOfModerator)).toEqual([ACTOR_ID, TARGET_ID]);
});
});
describe('isNoopGuildAuditLog', () => {
it.each(SKIPPABLE_ACTIONS)('treats action %i with empty or null changes as a no-op', (actionType) => {
expect(isNoopGuildAuditLog(actionType, [])).toBe(true);
expect(isNoopGuildAuditLog(actionType, null)).toBe(true);
expect(isNoopGuildAuditLog(actionType, undefined)).toBe(true);
});
it.each(SKIPPABLE_ACTIONS)('treats action %i with only an ip change as a no-op', (actionType) => {
expect(isNoopGuildAuditLog(actionType, [{key: 'ip', new_value: '203.0.113.1'}])).toBe(true);
});
it.each(SKIPPABLE_ACTIONS)('keeps action %i with a real change', (actionType) => {
expect(isNoopGuildAuditLog(actionType, [{key: 'name', old_value: 'a', new_value: 'b'}])).toBe(false);
});
it('lists the internal guild change keys', () => {
expect([...GUILD_AUDIT_INTERNAL_CHANGE_KEYS].sort()).toEqual(
[
'guild_id',
'member_count',
'banner_width',
'banner_height',
'splash_width',
'splash_height',
'embed_splash_width',
'embed_splash_height',
].sort(),
);
});
it.each([...GUILD_AUDIT_INTERNAL_CHANGE_KEYS])('treats a guild update of only %s as a no-op', (key) => {
expect(isNoopGuildAuditLog(AuditLogActionType.GUILD_UPDATE, [{key, old_value: 1, new_value: 2}])).toBe(true);
});
it('treats a dimension-only guild update as a no-op', () => {
expect(
isNoopGuildAuditLog(AuditLogActionType.GUILD_UPDATE, [
{key: 'banner_width', old_value: 960, new_value: 1920},
{key: 'banner_height', old_value: 540, new_value: 1080},
{key: 'ip', new_value: '203.0.113.1'},
]),
).toBe(true);
});
it('keeps a guild update that renames the guild', () => {
expect(
isNoopGuildAuditLog(AuditLogActionType.GUILD_UPDATE, [
{key: 'banner_width', old_value: 960, new_value: 1920},
{key: 'name', old_value: 'Old', new_value: 'New'},
]),
).toBe(false);
});
it('only ignores internal keys for guild updates', () => {
expect(
isNoopGuildAuditLog(AuditLogActionType.MEMBER_UPDATE, [{key: 'banner_width', old_value: 1, new_value: 2}]),
).toBe(false);
});
it('treats a member move without changes as a no-op but keeps a disconnect', () => {
expect(isNoopGuildAuditLog(AuditLogActionType.MEMBER_MOVE, null)).toBe(true);
expect(isNoopGuildAuditLog(AuditLogActionType.MEMBER_DISCONNECT, null)).toBe(false);
});
it.each(RECORDED_ACTIONS)('never treats action %i as a no-op', (actionType) => {
expect(isNoopGuildAuditLog(actionType, [])).toBe(false);
expect(isNoopGuildAuditLog(actionType, null)).toBe(false);
});
});
@@ -0,0 +1,183 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {setupTestGuildWithMembers, updateGuild} from '@app/api/guild/tests/GuildTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS, TEST_CREDENTIALS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface AuditLogChange {
key: string;
old_value?: unknown;
new_value?: unknown;
}
interface AuditLogEntry {
id: string;
action_type: number;
user_id: string | null;
target_id: string | null;
reason?: string;
options?: Record<string, unknown>;
changes?: Array<AuditLogChange>;
}
interface AuditLogResponse {
audit_log_entries: Array<AuditLogEntry>;
users: Array<{
id: string;
}>;
}
async function getGuildUpdateLogs(harness: ApiTestHarness, token: string, guildId: string): Promise<AuditLogResponse> {
return createBuilder<AuditLogResponse>(harness, token)
.get(`/guilds/${guildId}/audit-logs?action_type=${AuditLogActionType.GUILD_UPDATE}`)
.expect(HTTP_STATUS.OK)
.execute();
}
async function addGuildFeaturesForTesting(
harness: ApiTestHarness,
guildId: string,
features: Array<string>,
): Promise<void> {
await createBuilder<{
success: boolean;
}>(harness, '')
.post(`/test/guilds/${guildId}/features`)
.body({add_features: features})
.execute();
}
async function setVanityCode(harness: ApiTestHarness, token: string, guildId: string, code: string | null) {
await createBuilder<{
code: string | null;
}>(harness, token)
.patch(`/guilds/${guildId}/vanity-url`)
.body({code})
.expect(HTTP_STATUS.OK)
.execute();
}
describe('Guild update audit log writers', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('records a rename with an afk timeout change as exactly those two changes', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
await updateGuild(harness, owner.token, guild.id, {name: 'Renamed Guild', afk_timeout: 900});
const response = await getGuildUpdateLogs(harness, owner.token, guild.id);
expect(response.audit_log_entries).toHaveLength(1);
const entry = response.audit_log_entries[0];
expect(entry?.user_id).toBe(owner.userId);
expect(entry?.target_id).toBe(guild.id);
expect(entry?.options).toBeUndefined();
expect(entry?.changes).toEqual([
{key: 'name', old_value: guild.name, new_value: 'Renamed Guild'},
{key: 'afk_timeout', old_value: 300, new_value: 900},
]);
});
test('records feature toggles as features changes', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const enabled = await updateGuild(harness, owner.token, guild.id, {
features: [...guild.features, GuildFeatures.INVITES_DISABLED, GuildFeatures.CLONE_EMOJI_ENABLED],
});
await updateGuild(harness, owner.token, guild.id, {
features: enabled.features.filter((feature: string) => feature !== GuildFeatures.INVITES_DISABLED),
});
const response = await getGuildUpdateLogs(harness, owner.token, guild.id);
expect(response.audit_log_entries).toHaveLength(2);
const [disableEntry, enableEntry] = response.audit_log_entries;
expect(enableEntry?.options).toBeUndefined();
expect(enableEntry?.changes?.map((change) => change.key)).toEqual(['features']);
const enableChange = enableEntry?.changes?.[0];
expect(enableChange?.old_value).not.toContain(GuildFeatures.INVITES_DISABLED);
expect(enableChange?.old_value).not.toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
expect(enableChange?.new_value).toContain(GuildFeatures.INVITES_DISABLED);
expect(enableChange?.new_value).toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
expect(disableEntry?.options).toBeUndefined();
expect(disableEntry?.changes?.map((change) => change.key)).toEqual(['features']);
const disableChange = disableEntry?.changes?.[0];
expect(disableChange?.old_value).toContain(GuildFeatures.INVITES_DISABLED);
expect(disableChange?.new_value).not.toContain(GuildFeatures.INVITES_DISABLED);
expect(disableChange?.new_value).toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
});
test('writes nothing when a patch sends the current name', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
await updateGuild(harness, owner.token, guild.id, {name: guild.name});
const response = await getGuildUpdateLogs(harness, owner.token, guild.id);
expect(response.audit_log_entries).toEqual([]);
});
test('records the audit log reason header', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const auditLogReason = 'Rebranding the community';
await createBuilder<GuildResponse>(harness, owner.token)
.patch(`/guilds/${guild.id}`)
.header('X-Audit-Log-Reason', auditLogReason)
.body({name: 'Reasoned Guild'})
.expect(HTTP_STATUS.OK)
.execute();
const response = await getGuildUpdateLogs(harness, owner.token, guild.id);
expect(response.audit_log_entries).toHaveLength(1);
const entry = response.audit_log_entries[0];
expect(entry?.reason).toBe(auditLogReason);
expect(entry?.options).toBeUndefined();
expect(entry?.changes).toEqual([{key: 'name', old_value: guild.name, new_value: 'Reasoned Guild'}]);
});
test('records an ownership transfer as only an owner_id change', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
await createBuilder<GuildResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/transfer-ownership`)
.body({new_owner_id: member.userId, password: TEST_CREDENTIALS.STRONG_PASSWORD})
.expect(HTTP_STATUS.OK)
.execute();
const response = await getGuildUpdateLogs(harness, member.token, guild.id);
expect(response.audit_log_entries).toHaveLength(1);
const entry = response.audit_log_entries[0];
expect(entry?.user_id).toBe(owner.userId);
expect(entry?.target_id).toBe(guild.id);
expect(entry?.options).toBeUndefined();
expect(entry?.changes).toEqual([{key: 'owner_id', old_value: owner.userId, new_value: member.userId}]);
const userIds = response.users.map((user) => user.id);
expect(userIds).toContain(owner.userId);
expect(userIds).toContain(member.userId);
});
test('writes nothing when ownership is transferred to the current owner', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
await createBuilder<GuildResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/transfer-ownership`)
.body({new_owner_id: owner.userId, password: TEST_CREDENTIALS.STRONG_PASSWORD})
.expect(HTTP_STATUS.OK)
.execute();
const response = await getGuildUpdateLogs(harness, owner.token, guild.id);
expect(response.audit_log_entries).toEqual([]);
});
test('records vanity url set, change and removal as only vanity_url_code changes', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
await addGuildFeaturesForTesting(harness, guild.id, [GuildFeatures.VANITY_URL]);
const firstCode = 'audit-vanity-one';
const secondCode = 'audit-vanity-two';
await setVanityCode(harness, owner.token, guild.id, firstCode);
await setVanityCode(harness, owner.token, guild.id, secondCode);
await setVanityCode(harness, owner.token, guild.id, null);
const response = await getGuildUpdateLogs(harness, owner.token, guild.id);
expect(response.audit_log_entries).toHaveLength(3);
const [removeEntry, changeEntry, setEntry] = response.audit_log_entries;
for (const entry of response.audit_log_entries) {
expect(entry.user_id).toBe(owner.userId);
expect(entry.target_id).toBe(guild.id);
expect(entry.options).toBeUndefined();
}
expect(setEntry?.changes).toEqual([{key: 'vanity_url_code', old_value: null, new_value: firstCode}]);
expect(changeEntry?.changes).toEqual([{key: 'vanity_url_code', old_value: firstCode, new_value: secondCode}]);
expect(removeEntry?.changes).toEqual([{key: 'vanity_url_code', old_value: secondCode, new_value: null}]);
});
});
@@ -0,0 +1,386 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
addMemberRole,
createChannel,
createRole,
removeMemberRole,
setupTestGuildWithMembers,
} from '@app/api/guild/tests/GuildTestUtils';
import {getGatewayService} from '@app/api/middleware/ServiceRegistry';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {MentionReplyPreferences} from '@fluxer/constants/src/UserConstants';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
interface AuditLogChange {
key: string;
old_value?: unknown;
new_value?: unknown;
}
interface AuditLogEntry {
id: string;
action_type: number;
user_id: string | null;
target_id: string | null;
reason?: string;
options?: Record<string, unknown>;
changes?: Array<AuditLogChange>;
}
interface AuditLogResponse {
audit_log_entries: Array<AuditLogEntry>;
users: Array<{
id: string;
}>;
}
async function listAuditLogs(
harness: ApiTestHarness,
token: string,
guildId: string,
actionType: AuditLogActionType,
): Promise<AuditLogResponse> {
return createBuilder<AuditLogResponse>(harness, token)
.get(`/guilds/${guildId}/audit-logs?action_type=${actionType}`)
.expect(HTTP_STATUS.OK)
.execute();
}
async function listTargetEntries(
harness: ApiTestHarness,
token: string,
guildId: string,
actionType: AuditLogActionType,
targetId: string,
): Promise<Array<AuditLogEntry>> {
const response = await listAuditLogs(harness, token, guildId, actionType);
return response.audit_log_entries.filter((entry) => entry.target_id === targetId);
}
function findChange(entry: AuditLogEntry | undefined, key: string): AuditLogChange | undefined {
return entry?.changes?.find((change) => change.key === key);
}
async function banMember(
harness: ApiTestHarness,
token: string,
guildId: string,
userId: string,
body: Record<string, unknown>,
headerReason?: string,
): Promise<void> {
const builder = createBuilder(harness, token).put(`/guilds/${guildId}/bans/${userId}`).body(body);
if (headerReason !== undefined) {
builder.header('X-Audit-Log-Reason', headerReason);
}
await builder.expect(HTTP_STATUS.NO_CONTENT).execute();
}
async function patchMember(
harness: ApiTestHarness,
token: string,
guildId: string,
userId: string,
body: Record<string, unknown>,
headerReason?: string,
): Promise<void> {
const builder = createBuilder(harness, token).patch(`/guilds/${guildId}/members/${userId}`).body(body);
if (headerReason !== undefined) {
builder.header('X-Audit-Log-Reason', headerReason);
}
await builder.expect(HTTP_STATUS.OK).execute();
}
function oneHourFromNow(): string {
return new Date(Date.now() + 60 * 60 * 1000).toISOString();
}
describe('Guild audit log member and moderation writers', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('records a kick with the header reason', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
await createBuilder(harness, owner.token)
.delete(`/guilds/${guild.id}/members/${member.userId}`)
.header('X-Audit-Log-Reason', 'Kick header reason')
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const entries = await listTargetEntries(
harness,
owner.token,
guild.id,
AuditLogActionType.MEMBER_KICK,
member.userId,
);
expect(entries).toHaveLength(1);
expect(entries[0]?.user_id).toBe(owner.userId);
expect(entries[0]?.reason).toBe('Kick header reason');
});
test('uses the body reason for a ban without a header and records no options', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
await banMember(harness, owner.token, guild.id, member.userId, {reason: 'Body ban reason'});
const entries = await listTargetEntries(
harness,
owner.token,
guild.id,
AuditLogActionType.MEMBER_BAN_ADD,
member.userId,
);
expect(entries).toHaveLength(1);
expect(entries[0]?.reason).toBe('Body ban reason');
expect(entries[0]?.options).toBeUndefined();
expect(findChange(entries[0], 'reason')?.new_value).toBe('Body ban reason');
});
test('records delete_message_seconds for a ban that deletes messages', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
await banMember(harness, owner.token, guild.id, member.userId, {delete_message_seconds: 3600});
const entries = await listTargetEntries(
harness,
owner.token,
guild.id,
AuditLogActionType.MEMBER_BAN_ADD,
member.userId,
);
expect(entries).toHaveLength(1);
expect(entries[0]?.options).toEqual({delete_message_seconds: 3600});
});
test('records delete_message_days as delete_message_seconds', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
await banMember(harness, owner.token, guild.id, member.userId, {delete_message_days: 2});
const entries = await listTargetEntries(
harness,
owner.token,
guild.id,
AuditLogActionType.MEMBER_BAN_ADD,
member.userId,
);
expect(entries).toHaveLength(1);
expect(entries[0]?.options).toEqual({delete_message_seconds: 172800});
});
test('keeps the header as the ban entry reason and the body reason in changes', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
await banMember(harness, owner.token, guild.id, member.userId, {reason: 'Body ban reason'}, 'Header ban reason');
const entries = await listTargetEntries(
harness,
owner.token,
guild.id,
AuditLogActionType.MEMBER_BAN_ADD,
member.userId,
);
expect(entries).toHaveLength(1);
expect(entries[0]?.reason).toBe('Header ban reason');
expect(findChange(entries[0], 'reason')?.new_value).toBe('Body ban reason');
});
test('records expires_at for a temporary ban', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
const bannedAfter = Date.now();
await banMember(harness, owner.token, guild.id, member.userId, {ban_duration_seconds: 3600});
const entries = await listTargetEntries(
harness,
owner.token,
guild.id,
AuditLogActionType.MEMBER_BAN_ADD,
member.userId,
);
expect(entries).toHaveLength(1);
const expiresAt = findChange(entries[0], 'expires_at')?.new_value;
expect(typeof expiresAt).toBe('string');
expect(Date.parse(expiresAt as string)).toBeGreaterThanOrEqual(bannedAfter + 3600 * 1000);
});
test('lists the original moderator when a different moderator unbans', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
const [moderator, target] = members;
const moderatorRole = await createRole(harness, owner.token, guild.id, {
name: 'Ban moderator',
permissions: Permissions.BAN_MEMBERS.toString(),
});
await addMemberRole(harness, owner.token, guild.id, moderator.userId, moderatorRole.id);
await banMember(harness, moderator.token, guild.id, target.userId, {});
await createBuilder(harness, owner.token)
.delete(`/guilds/${guild.id}/bans/${target.userId}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const response = await listAuditLogs(harness, owner.token, guild.id, AuditLogActionType.MEMBER_BAN_REMOVE);
const entry = response.audit_log_entries.find((log) => log.target_id === target.userId);
expect(entry?.user_id).toBe(owner.userId);
expect(findChange(entry, 'moderator_id')?.old_value).toBe(moderator.userId);
expect(response.users.map((user) => user.id)).toContain(moderator.userId);
});
test('uses timeout_reason as the entry reason unless a header reason is sent', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
const [bodyOnlyTarget, headerTarget] = members;
await patchMember(harness, owner.token, guild.id, bodyOnlyTarget.userId, {
communication_disabled_until: oneHourFromNow(),
timeout_reason: 'Timeout body reason',
});
await patchMember(
harness,
owner.token,
guild.id,
headerTarget.userId,
{
communication_disabled_until: oneHourFromNow(),
timeout_reason: 'Timeout body reason',
},
'Timeout header reason',
);
const bodyOnlyEntries = await listTargetEntries(
harness,
owner.token,
guild.id,
AuditLogActionType.MEMBER_UPDATE,
bodyOnlyTarget.userId,
);
expect(bodyOnlyEntries).toHaveLength(1);
expect(bodyOnlyEntries[0]?.reason).toBe('Timeout body reason');
expect(bodyOnlyEntries[0]?.options).toBeUndefined();
expect(typeof findChange(bodyOnlyEntries[0], 'communication_disabled_until')?.new_value).toBe('string');
const headerEntries = await listTargetEntries(
harness,
owner.token,
guild.id,
AuditLogActionType.MEMBER_UPDATE,
headerTarget.userId,
);
expect(headerEntries).toHaveLength(1);
expect(headerEntries[0]?.reason).toBe('Timeout header reason');
expect(headerEntries[0]?.options).toBeUndefined();
});
test('writes nothing when clearing a timeout on a member without one', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
await patchMember(harness, owner.token, guild.id, member.userId, {
communication_disabled_until: null,
timeout_reason: 'Nothing to clear',
});
const entries = await listTargetEntries(
harness,
owner.token,
guild.id,
AuditLogActionType.MEMBER_UPDATE,
member.userId,
);
expect(entries).toHaveLength(0);
});
test('writes nothing for a mention_flags only update of the current member', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
await createBuilder(harness, member.token)
.patch(`/guilds/${guild.id}/members/@me`)
.body({mention_flags: MentionReplyPreferences.PREFER_NO_MENTION})
.expect(HTTP_STATUS.OK)
.execute();
const entries = await listTargetEntries(
harness,
owner.token,
guild.id,
AuditLogActionType.MEMBER_UPDATE,
member.userId,
);
expect(entries).toHaveLength(0);
});
test('records a repeated nickname only once', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
await patchMember(harness, owner.token, guild.id, member.userId, {nick: 'Audit Nick'});
await patchMember(harness, owner.token, guild.id, member.userId, {nick: 'Audit Nick'});
const entries = await listTargetEntries(
harness,
owner.token,
guild.id,
AuditLogActionType.MEMBER_UPDATE,
member.userId,
);
expect(entries).toHaveLength(1);
expect(findChange(entries[0], 'nick')).toEqual({key: 'nick', old_value: null, new_value: 'Audit Nick'});
});
test('records role_name for member role changes and skips adding a role the member has', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
const role = await createRole(harness, owner.token, guild.id, {
name: 'Audit Member Role',
permissions: Permissions.VIEW_CHANNEL.toString(),
});
await addMemberRole(harness, owner.token, guild.id, member.userId, role.id);
await addMemberRole(harness, owner.token, guild.id, member.userId, role.id);
await removeMemberRole(harness, owner.token, guild.id, member.userId, role.id);
const entries = await listTargetEntries(
harness,
owner.token,
guild.id,
AuditLogActionType.MEMBER_ROLE_UPDATE,
member.userId,
);
expect(entries).toHaveLength(2);
const [removeEntry, addEntry] = entries;
expect(addEntry?.options).toEqual({role_name: 'Audit Member Role'});
expect(findChange(addEntry, 'roles')).toEqual({key: 'roles', old_value: [], new_value: [role.id]});
expect(removeEntry?.options).toEqual({role_name: 'Audit Member Role'});
expect(findChange(removeEntry, 'roles')).toEqual({key: 'roles', old_value: [role.id], new_value: []});
});
test('writes nothing for a voice move into the channel the member is already in', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
const firstChannel = await createChannel(harness, owner.token, guild.id, 'First voice', ChannelTypes.GUILD_VOICE);
const secondChannel = await createChannel(harness, owner.token, guild.id, 'Second voice', ChannelTypes.GUILD_VOICE);
const getVoiceState = vi.spyOn(getGatewayService(), 'getVoiceState');
try {
getVoiceState.mockResolvedValue({channel_id: firstChannel.id});
await patchMember(harness, owner.token, guild.id, member.userId, {channel_id: secondChannel.id});
getVoiceState.mockResolvedValue({channel_id: secondChannel.id});
await patchMember(harness, owner.token, guild.id, member.userId, {channel_id: secondChannel.id});
} finally {
getVoiceState.mockRestore();
}
const moves = await listTargetEntries(
harness,
owner.token,
guild.id,
AuditLogActionType.MEMBER_MOVE,
member.userId,
);
expect(moves).toHaveLength(1);
expect(findChange(moves[0], 'channel_id')).toEqual({
key: 'channel_id',
old_value: firstChannel.id,
new_value: secondChannel.id,
});
const updates = await listTargetEntries(
harness,
owner.token,
guild.id,
AuditLogActionType.MEMBER_UPDATE,
member.userId,
);
expect(updates).toHaveLength(0);
});
});
@@ -0,0 +1,162 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
import {deleteMessage, sendMessage} from '@app/api/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {createWebhook, executeWebhook} from '@app/api/webhook/tests/WebhookTestUtils';
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface AuditLogOptions {
channel_id?: string;
count?: number;
message_id?: string;
}
interface AuditLogEntry {
id: string;
action_type: number;
user_id: string | null;
target_id: string | null;
reason?: string;
options?: AuditLogOptions;
}
interface AuditLogResponse {
audit_log_entries: Array<AuditLogEntry>;
}
async function fetchAuditLogEntries(
harness: ApiTestHarness,
token: string,
guildId: string,
actionType: AuditLogActionType,
): Promise<Array<AuditLogEntry>> {
const response = await createBuilder<AuditLogResponse>(harness, token)
.get(`/guilds/${guildId}/audit-logs?action_type=${actionType}`)
.expect(HTTP_STATUS.OK)
.execute();
return response.audit_log_entries;
}
function requireEntry(entries: Array<AuditLogEntry>, predicate: (entry: AuditLogEntry) => boolean): AuditLogEntry {
const entry = entries.find(predicate);
if (!entry) {
throw new Error('Expected audit log entry was not recorded');
}
return entry;
}
describe('Guild audit log message writers', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test("records a moderator deleting another member's message with the header reason", async () => {
const {owner, members, guild, channels} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
const channel = channels[0];
const message = await sendMessage(harness, member.token, channel.id, 'please remove this');
const reason = 'Off-topic';
await createBuilder(harness, owner.token)
.delete(`/channels/${channel.id}/messages/${message.id}`)
.header('X-Audit-Log-Reason', reason)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const entries = await fetchAuditLogEntries(harness, owner.token, guild.id, AuditLogActionType.MESSAGE_DELETE);
const entry = requireEntry(entries, (candidate) => candidate.target_id === message.id);
expect(entry.user_id).toBe(owner.userId);
expect(entry.reason).toBe(reason);
expect(entry.options?.channel_id).toBe(channel.id);
});
test('records an author deleting their own message', async () => {
const {owner, members, guild, channels} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
const channel = channels[0];
const message = await sendMessage(harness, member.token, channel.id, 'my own mistake');
await deleteMessage(harness, member.token, channel.id, message.id);
const entries = await fetchAuditLogEntries(harness, owner.token, guild.id, AuditLogActionType.MESSAGE_DELETE);
const entry = requireEntry(entries, (candidate) => candidate.target_id === message.id);
expect(entry.user_id).toBe(member.userId);
expect(entry.reason).toBeUndefined();
expect(entry.options?.channel_id).toBe(channel.id);
});
test('records a moderator deleting a webhook message', async () => {
const {owner, guild, channels} = await setupTestGuildWithMembers(harness, 0);
const channel = channels[0];
const webhook = await createWebhook(harness, channel.id, owner.token, 'Relay');
const {json: message} = await executeWebhook(
harness,
webhook.id,
webhook.token,
{content: 'relayed update', wait: true},
200,
);
if (!message) {
throw new Error('Webhook execution did not return a message');
}
const reason = 'Relay spam';
await createBuilder(harness, owner.token)
.delete(`/channels/${channel.id}/messages/${message.id}`)
.header('X-Audit-Log-Reason', reason)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const entries = await fetchAuditLogEntries(harness, owner.token, guild.id, AuditLogActionType.MESSAGE_DELETE);
const entry = requireEntry(entries, (candidate) => candidate.target_id === message.id);
expect(entry.user_id).toBe(owner.userId);
expect(entry.reason).toBe(reason);
expect(entry.options?.channel_id).toBe(channel.id);
});
test('records a bulk delete with the header reason and a numeric count', async () => {
const {owner, guild, channels} = await setupTestGuildWithMembers(harness, 0);
const channel = channels[0];
const messageIds: Array<string> = [];
for (let index = 0; index < 3; index++) {
const message = await sendMessage(harness, owner.token, channel.id, `bulk ${index}`);
messageIds.push(message.id);
}
const reason = 'Clearing a raid';
await createBuilder(harness, owner.token)
.post(`/channels/${channel.id}/messages/bulk-delete`)
.header('X-Audit-Log-Reason', reason)
.body({message_ids: messageIds})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const entries = await fetchAuditLogEntries(harness, owner.token, guild.id, AuditLogActionType.MESSAGE_BULK_DELETE);
const entry = requireEntry(entries, (candidate) => candidate.options?.channel_id === channel.id);
expect(entry.user_id).toBe(owner.userId);
expect(entry.reason).toBe(reason);
expect(entry.options?.count).toBe(3);
});
test('records pin and unpin with the header reason', async () => {
const {owner, guild, channels} = await setupTestGuildWithMembers(harness, 0);
const channel = channels[0];
const message = await sendMessage(harness, owner.token, channel.id, 'pin me');
await createBuilder(harness, owner.token)
.put(`/channels/${channel.id}/pins/${message.id}`)
.header('X-Audit-Log-Reason', 'Important announcement')
.body(null)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilder(harness, owner.token)
.delete(`/channels/${channel.id}/pins/${message.id}`)
.header('X-Audit-Log-Reason', 'Announcement expired')
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const pinEntries = await fetchAuditLogEntries(harness, owner.token, guild.id, AuditLogActionType.MESSAGE_PIN);
const pinEntry = requireEntry(pinEntries, (candidate) => candidate.target_id === message.id);
expect(pinEntry.user_id).toBe(owner.userId);
expect(pinEntry.reason).toBe('Important announcement');
expect(pinEntry.options?.channel_id).toBe(channel.id);
const unpinEntries = await fetchAuditLogEntries(harness, owner.token, guild.id, AuditLogActionType.MESSAGE_UNPIN);
const unpinEntry = requireEntry(unpinEntries, (candidate) => candidate.target_id === message.id);
expect(unpinEntry.user_id).toBe(owner.userId);
expect(unpinEntry.reason).toBe('Announcement expired');
expect(unpinEntry.options?.channel_id).toBe(channel.id);
});
});
@@ -0,0 +1,274 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createGuild, createRole, updateRole, updateRolePositions} from '@app/api/guild/tests/GuildTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface AuditLogChange {
key: string;
old_value?: unknown;
new_value?: unknown;
}
interface AuditLogOptions {
role_name?: string;
}
interface AuditLogEntry {
id: string;
action_type: number;
user_id: string | null;
target_id: string | null;
reason?: string;
options?: AuditLogOptions;
changes?: Array<AuditLogChange>;
}
interface AuditLogResponse {
audit_log_entries: Array<AuditLogEntry>;
}
const ROLE_SNAPSHOT_KEYS = [
'role_id',
'name',
'permissions',
'position',
'hoist_position',
'color',
'icon_hash',
'unicode_emoji',
'hoist',
'mentionable',
];
async function listEntries(
harness: ApiTestHarness,
token: string,
guildId: string,
actionType: AuditLogActionType,
): Promise<Array<AuditLogEntry>> {
const response = await createBuilder<AuditLogResponse>(harness, token)
.get(`/guilds/${guildId}/audit-logs?action_type=${actionType}&limit=100`)
.expect(HTTP_STATUS.OK)
.execute();
return response.audit_log_entries;
}
function hasChangeKey(entry: AuditLogEntry, key: string): boolean {
return entry.changes?.some((change) => change.key === key) ?? false;
}
describe('Guild audit log role writers', () => {
let harness: ApiTestHarness;
let owner: TestAccount;
let guild: GuildResponse;
beforeEach(async () => {
harness = await createApiTestHarness();
owner = await createTestAccount(harness);
guild = await createGuild(harness, owner.token, 'Role Audit Guild');
});
afterEach(async () => {
await harness?.shutdown();
});
async function createOrderedRoles(names: Array<string>) {
const roles = [];
for (const name of names) {
roles.push(await createRole(harness, owner.token, guild.id, {name}));
}
await updateRolePositions(
harness,
owner.token,
guild.id,
roles.map((role, index) => ({id: role.id, position: roles.length - index})),
);
return roles;
}
test('records the full role snapshot on create without options', async () => {
const role = await createRole(harness, owner.token, guild.id, {
name: 'Snapshot',
color: 0xe67e22,
permissions: Permissions.VIEW_CHANNEL.toString(),
});
const entries = await listEntries(harness, owner.token, guild.id, AuditLogActionType.ROLE_CREATE);
const entry = entries.find((log) => log.target_id === role.id);
expect(entry).toBeDefined();
expect(entry?.user_id).toBe(owner.userId);
expect(entry?.options).toBeUndefined();
expect(entry?.changes?.map((change) => change.key)).toEqual(ROLE_SNAPSHOT_KEYS);
expect(entry?.changes).toEqual([
{key: 'role_id', new_value: role.id},
{key: 'name', new_value: 'Snapshot'},
{key: 'permissions', new_value: role.permissions},
{key: 'position', new_value: 1},
{key: 'hoist_position', new_value: null},
{key: 'color', new_value: 0xe67e22},
{key: 'icon_hash', new_value: null},
{key: 'unicode_emoji', new_value: null},
{key: 'hoist', new_value: false},
{key: 'mentionable', new_value: false},
]);
});
test('writes nothing for a role patch that changes nothing', async () => {
const role = await createRole(harness, owner.token, guild.id, {name: 'Stable', color: 0x3498db});
await updateRole(harness, owner.token, guild.id, role.id, {name: 'Stable', color: 0x3498db});
const entries = await listEntries(harness, owner.token, guild.id, AuditLogActionType.ROLE_UPDATE);
expect(entries.filter((log) => log.target_id === role.id)).toEqual([]);
});
test('records the new role name on a rename', async () => {
const role = await createRole(harness, owner.token, guild.id, {name: 'Mods'});
await createBuilder(harness, owner.token)
.patch(`/guilds/${guild.id}/roles/${role.id}`)
.header('X-Audit-Log-Reason', 'Clearer name')
.body({name: 'Moderators'})
.expect(HTTP_STATUS.OK)
.execute();
const entries = await listEntries(harness, owner.token, guild.id, AuditLogActionType.ROLE_UPDATE);
const roleEntries = entries.filter((log) => log.target_id === role.id);
expect(roleEntries).toHaveLength(1);
expect(roleEntries[0]?.reason).toBe('Clearer name');
expect(roleEntries[0]?.options).toEqual({role_name: 'Moderators'});
expect(roleEntries[0]?.changes).toEqual([{key: 'name', old_value: 'Mods', new_value: 'Moderators'}]);
});
test('keeps permissions_diff and records the role name on a permission update', async () => {
const role = await createRole(harness, owner.token, guild.id, {
name: 'Helpers',
permissions: Permissions.VIEW_CHANNEL.toString(),
});
await updateRole(harness, owner.token, guild.id, role.id, {
permissions: (Permissions.VIEW_CHANNEL | Permissions.SEND_MESSAGES).toString(),
});
const entries = await listEntries(harness, owner.token, guild.id, AuditLogActionType.ROLE_UPDATE);
const entry = entries.find((log) => log.target_id === role.id);
expect(entry?.options).toEqual({role_name: 'Helpers'});
expect(entry?.changes?.map((change) => change.key)).toEqual(['permissions', 'permissions_diff']);
});
test('writes no role update for the duplicate flow', async () => {
const source = await createRole(harness, owner.token, guild.id, {
name: 'Source',
color: 0xe67e22,
permissions: Permissions.VIEW_CHANNEL.toString(),
});
const copy = await createRole(harness, owner.token, guild.id, {
name: 'Source copy',
color: source.color,
permissions: source.permissions,
});
await updateRole(harness, owner.token, guild.id, copy.id, {hoist: false, mentionable: false});
const createEntries = await listEntries(harness, owner.token, guild.id, AuditLogActionType.ROLE_CREATE);
expect(createEntries.some((log) => log.target_id === copy.id)).toBe(true);
const updateEntries = await listEntries(harness, owner.token, guild.id, AuditLogActionType.ROLE_UPDATE);
expect(updateEntries.filter((log) => log.target_id === copy.id)).toEqual([]);
});
test('writes nothing for a reorder that only renumbers roles', async () => {
await createOrderedRoles(['Role A', 'Role B', 'Role C']);
const entries = await listEntries(harness, owner.token, guild.id, AuditLogActionType.ROLE_UPDATE);
expect(entries).toEqual([]);
});
test('records only the dragged role when a reorder moves one role', async () => {
const [roleA, roleB, roleC] = await createOrderedRoles(['Role A', 'Role B', 'Role C']);
await createBuilder(harness, owner.token)
.patch(`/guilds/${guild.id}/roles`)
.header('X-Audit-Log-Reason', 'Promote C')
.body([
{id: roleC!.id, position: 3},
{id: roleA!.id, position: 2},
{id: roleB!.id, position: 1},
])
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const entries = await listEntries(harness, owner.token, guild.id, AuditLogActionType.ROLE_UPDATE);
expect(entries).toHaveLength(1);
expect(entries[0]?.target_id).toBe(roleC!.id);
expect(entries[0]?.reason).toBe('Promote C');
expect(entries[0]?.options).toEqual({role_name: 'Role C'});
expect(entries[0]?.changes).toEqual([{key: 'position', old_value: 1, new_value: 3}]);
});
test('records hoist position set and reset only for the role that moved in the member list', async () => {
const [roleA, roleB, roleC] = await createOrderedRoles(['Role A', 'Role B', 'Role C']);
for (const role of [roleA!, roleB!, roleC!]) {
await updateRole(harness, owner.token, guild.id, role.id, {hoist: true});
}
await createBuilder(harness, owner.token)
.patch(`/guilds/${guild.id}/roles/hoist-positions`)
.body([
{id: roleC!.id, hoist_position: 3},
{id: roleA!.id, hoist_position: 2},
{id: roleB!.id, hoist_position: 1},
])
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const afterSet = await listEntries(harness, owner.token, guild.id, AuditLogActionType.ROLE_UPDATE);
const setEntries = afterSet.filter((log) => hasChangeKey(log, 'hoist_position'));
expect(setEntries).toHaveLength(1);
expect(setEntries[0]?.target_id).toBe(roleC!.id);
expect(setEntries[0]?.options).toEqual({role_name: 'Role C'});
expect(setEntries[0]?.changes).toEqual([{key: 'hoist_position', old_value: null, new_value: 3}]);
await createBuilder(harness, owner.token)
.delete(`/guilds/${guild.id}/roles/hoist-positions`)
.header('X-Audit-Log-Reason', 'Back to role order')
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const afterReset = await listEntries(harness, owner.token, guild.id, AuditLogActionType.ROLE_UPDATE);
const resetEntries = afterReset.filter((log) =>
log.changes?.some((change) => change.key === 'hoist_position' && change.new_value === null),
);
expect(resetEntries).toHaveLength(1);
expect(resetEntries[0]?.target_id).toBe(roleC!.id);
expect(resetEntries[0]?.reason).toBe('Back to role order');
expect(resetEntries[0]?.options).toEqual({role_name: 'Role C'});
expect(resetEntries[0]?.changes).toEqual([{key: 'hoist_position', old_value: 3, new_value: null}]);
});
test('records a hoist position write for a role outside the member list', async () => {
const role = await createRole(harness, owner.token, guild.id, {name: 'Hidden'});
await createBuilder(harness, owner.token)
.patch(`/guilds/${guild.id}/roles/hoist-positions`)
.body([{id: role.id, hoist_position: 5}])
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const entries = await listEntries(harness, owner.token, guild.id, AuditLogActionType.ROLE_UPDATE);
expect(entries).toHaveLength(1);
expect(entries[0]?.target_id).toBe(role.id);
expect(entries[0]?.options).toEqual({role_name: 'Hidden'});
expect(entries[0]?.changes).toEqual([{key: 'hoist_position', old_value: null, new_value: 5}]);
});
test('records the role snapshot and reason on delete', async () => {
const role = await createRole(harness, owner.token, guild.id, {
name: 'Doomed',
permissions: Permissions.VIEW_CHANNEL.toString(),
});
await createBuilder(harness, owner.token)
.delete(`/guilds/${guild.id}/roles/${role.id}`)
.header('X-Audit-Log-Reason', 'Cleanup')
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const entries = await listEntries(harness, owner.token, guild.id, AuditLogActionType.ROLE_DELETE);
const entry = entries.find((log) => log.target_id === role.id);
expect(entry).toBeDefined();
expect(entry?.reason).toBe('Cleanup');
expect(entry?.options).toBeUndefined();
expect(entry?.changes?.map((change) => change.key)).toEqual(ROLE_SNAPSHOT_KEYS);
expect(entry?.changes?.find((change) => change.key === 'name')).toEqual({key: 'name', old_value: 'Doomed'});
expect(entry?.changes?.find((change) => change.key === 'permissions')).toEqual({
key: 'permissions',
old_value: role.permissions,
});
});
});
@@ -0,0 +1,378 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
createChannelID,
createGuildID,
createRoleID,
createUserID,
type RoleID,
type UserID,
} from '@app/api/BrandedTypes';
import type {GuildAuditLogRow} from '@app/api/database/types/GuildTypes';
import {mapGuildAuditLogEntry} from '@app/api/guild/GuildAuditLogEntryMapper';
import {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
import {ChannelPermissionOverwrite} from '@app/api/models/ChannelPermissionOverwrite';
import {GuildAuditLog} from '@app/api/models/GuildAuditLog';
import type {GuildRole} from '@app/api/models/GuildRole';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import {describe, expect, it, vi} from 'vitest';
const GUILD_ID = createGuildID(1420000000000000000n);
const EVERYONE_ROLE_ID = createRoleID(1420000000000000000n);
const ACTOR_ID = createUserID(1420000000000000001n);
const MEMBER_ID = createUserID(1420000000000000002n);
const ROLE_ID = createRoleID(1420000000000000003n);
const CHANNEL_ID = createChannelID(1420000000000000004n);
const SKIPPABLE_ACTIONS = [
AuditLogActionType.GUILD_UPDATE,
AuditLogActionType.CHANNEL_UPDATE,
AuditLogActionType.CHANNEL_OVERWRITE_UPDATE,
AuditLogActionType.MEMBER_UPDATE,
AuditLogActionType.MEMBER_ROLE_UPDATE,
AuditLogActionType.MEMBER_MOVE,
AuditLogActionType.ROLE_UPDATE,
AuditLogActionType.WEBHOOK_UPDATE,
AuditLogActionType.EMOJI_UPDATE,
AuditLogActionType.STICKER_UPDATE,
];
function createService(roleNames: Map<string, string> = new Map()) {
let nextLogId = 1420000000000001000n;
const createAuditLog = vi.fn(async (row: GuildAuditLogRow) => new GuildAuditLog(row));
const batchDeleteAndCreateAuditLogs = vi.fn(
async (_guildId: unknown, _logs: Array<GuildAuditLog>, row: GuildAuditLogRow) => new GuildAuditLog(row),
);
const getRole = vi.fn(async (roleId: RoleID) => {
const name = roleNames.get(roleId.toString());
return name === undefined ? null : ({id: roleId, name} as unknown as GuildRole);
});
const dispatchGuild = vi.fn().mockResolvedValue(undefined);
const addJob = vi.fn().mockResolvedValue(undefined);
const service = new GuildAuditLogService(
{createAuditLog, batchDeleteAndCreateAuditLogs, getRole} as unknown as IGuildRepositoryAggregate,
{generate: vi.fn(async () => nextLogId++)} as unknown as ISnowflakeService,
{addJob} as unknown as IWorkerService<WorkerTaskName>,
{dispatchGuild} as unknown as IGatewayService,
);
return {service, createAuditLog, batchDeleteAndCreateAuditLogs, getRole, dispatchGuild};
}
function overwrites(
entries: Array<[RoleID | UserID, {type: number; allow: bigint; deny: bigint}]>,
): Map<RoleID | UserID, ChannelPermissionOverwrite> {
return new Map(
entries.map(([id, overwrite]) => [
id,
new ChannelPermissionOverwrite({type: overwrite.type, allow_: overwrite.allow, deny_: overwrite.deny}),
]),
);
}
function writtenRow(createAuditLog: ReturnType<typeof createService>['createAuditLog'], index = 0): GuildAuditLogRow {
const call = createAuditLog.mock.calls[index];
if (!call) {
throw new Error(`expected audit log write ${index}`);
}
return call[0];
}
function writtenOptions(row: GuildAuditLogRow): Record<string, string> {
return Object.fromEntries(row.options ?? []);
}
describe('GuildAuditLogService dispatch', () => {
it('dispatches the REST entry shape plus guild_id for an invite', async () => {
const {service, dispatchGuild} = createService();
const log = await service
.createBuilder(GUILD_ID, ACTOR_ID)
.withAction(AuditLogActionType.INVITE_CREATE, 'abcdef')
.withMetadata({
max_uses: '0',
max_age: '604800',
temporary: 'false',
channel_id: CHANNEL_ID.toString(),
inviter_id: ACTOR_ID.toString(),
})
.withChanges(service.computeChanges(null, {code: 'abcdef', uses: 0}))
.commit();
expect(log).not.toBeNull();
expect(dispatchGuild).toHaveBeenCalledTimes(1);
expect(dispatchGuild).toHaveBeenCalledWith({
guildId: GUILD_ID,
event: 'GUILD_AUDIT_LOG_ENTRY_CREATE',
data: {...mapGuildAuditLogEntry(log!), guild_id: GUILD_ID.toString()},
});
const data = dispatchGuild.mock.calls[0]![0].data;
expect(data.options).toEqual({
max_uses: 0,
max_age: 604800,
temporary: false,
channel_id: CHANNEL_ID.toString(),
inviter_id: ACTOR_ID.toString(),
});
});
it('dispatches the resolved reason for a legacy ban row', async () => {
const {service, dispatchGuild} = createService();
const log = await service
.createBuilder(GUILD_ID, ACTOR_ID)
.withAction(AuditLogActionType.MEMBER_BAN_ADD, MEMBER_ID.toString())
.withMetadata({delete_member_days: '0'})
.withChanges([
{key: 'user_id', new_value: MEMBER_ID.toString()},
{key: 'reason', new_value: 'spam'},
])
.commit();
const data = dispatchGuild.mock.calls[0]![0].data;
expect(data).toEqual({...mapGuildAuditLogEntry(log!), guild_id: GUILD_ID.toString()});
expect(data.reason).toBe('spam');
expect(data.options).toEqual({delete_member_days: '0'});
});
it('does not dispatch raw timeout options', async () => {
const {service, dispatchGuild} = createService();
await service
.createBuilder(GUILD_ID, ACTOR_ID)
.withAction(AuditLogActionType.MEMBER_UPDATE, MEMBER_ID.toString())
.withMetadata({timeout_reason: 'cool off', communication_disabled_until: '2026-09-13T00:00:00.000Z'})
.withChanges([{key: 'communication_disabled_until', new_value: '2026-09-13T00:00:00.000Z'}])
.commit();
const data = dispatchGuild.mock.calls[0]![0].data;
expect(data.options).toBeUndefined();
expect(data.reason).toBe('cool off');
});
it('dispatches a batched bulk delete with a numeric count', async () => {
const {service, dispatchGuild, batchDeleteAndCreateAuditLogs} = createService();
const logs = [1420000000000000200n, 1420000000000000199n].map(
(logId) =>
new GuildAuditLog({
guild_id: GUILD_ID,
log_id: logId,
user_id: ACTOR_ID,
target_id: null,
action_type: AuditLogActionType.MESSAGE_DELETE,
reason: null,
options: new Map([['channel_id', CHANNEL_ID.toString()]]),
changes: null,
}),
);
const result = await service.batchConsecutiveMessageDeleteLogs(GUILD_ID, logs);
expect(batchDeleteAndCreateAuditLogs).toHaveBeenCalledTimes(1);
expect(result.createdLogs).toHaveLength(1);
expect(dispatchGuild).toHaveBeenCalledTimes(1);
const data = dispatchGuild.mock.calls[0]![0].data;
expect(data.action_type).toBe(AuditLogActionType.MESSAGE_BULK_DELETE);
expect(data.guild_id).toBe(GUILD_ID.toString());
expect(data.options).toEqual({channel_id: CHANNEL_ID.toString(), count: 2});
});
});
describe('GuildAuditLogBuilder.commit', () => {
it.each(SKIPPABLE_ACTIONS)('skips action %i with empty changes', async (actionType) => {
const {service, createAuditLog, dispatchGuild} = createService();
const result = await service
.createBuilder(GUILD_ID, ACTOR_ID)
.withAction(actionType, MEMBER_ID.toString())
.withReason('ignored reason')
.withMetadata({role_name: 'Moderators'})
.withChanges([])
.commit();
expect(result).toBeNull();
expect(createAuditLog).not.toHaveBeenCalled();
expect(dispatchGuild).not.toHaveBeenCalled();
});
it.each(SKIPPABLE_ACTIONS)('skips action %i without changes', async (actionType) => {
const {service, createAuditLog, dispatchGuild} = createService();
const result = await service
.createBuilder(GUILD_ID, ACTOR_ID)
.withAction(actionType, MEMBER_ID.toString())
.commit();
expect(result).toBeNull();
expect(createAuditLog).not.toHaveBeenCalled();
expect(dispatchGuild).not.toHaveBeenCalled();
});
it('skips a guild update that only changes internal keys', async () => {
const {service, createAuditLog} = createService();
const result = await service
.createBuilder(GUILD_ID, ACTOR_ID)
.withAction(AuditLogActionType.GUILD_UPDATE, GUILD_ID.toString())
.withChanges([
{key: 'member_count', old_value: 1, new_value: 2},
{key: 'splash_width', old_value: null, new_value: 1920},
])
.commit();
expect(result).toBeNull();
expect(createAuditLog).not.toHaveBeenCalled();
});
it('records a kick without changes', async () => {
const {service, createAuditLog, dispatchGuild} = createService();
const result = await service
.createBuilder(GUILD_ID, ACTOR_ID)
.withAction(AuditLogActionType.MEMBER_KICK, MEMBER_ID.toString())
.withReason('rule 1')
.commit();
expect(result).not.toBeNull();
expect(createAuditLog).toHaveBeenCalledTimes(1);
expect(writtenRow(createAuditLog).reason).toBe('rule 1');
expect(writtenRow(createAuditLog).changes).toBeNull();
expect(dispatchGuild).toHaveBeenCalledTimes(1);
});
it('records a webhook create with empty changes', async () => {
const {service, createAuditLog, dispatchGuild} = createService();
const result = await service
.createBuilder(GUILD_ID, ACTOR_ID)
.withAction(AuditLogActionType.WEBHOOK_CREATE, '1420000000000000005')
.withChanges([])
.commit();
expect(result).not.toBeNull();
expect(createAuditLog).toHaveBeenCalledTimes(1);
expect(dispatchGuild).toHaveBeenCalledTimes(1);
});
});
describe('GuildAuditLogService.recordPermissionOverwriteDiff', () => {
it('records role_name for a role overwrite', async () => {
const {service, createAuditLog, getRole} = createService(new Map([[ROLE_ID.toString(), 'Moderators']]));
await service.recordPermissionOverwriteDiff({
guildId: GUILD_ID,
userId: ACTOR_ID,
channelId: CHANNEL_ID,
previous: null,
next: overwrites([[ROLE_ID, {type: 0, allow: 1024n, deny: 0n}]]),
});
expect(getRole).toHaveBeenCalledTimes(1);
expect(getRole).toHaveBeenCalledWith(ROLE_ID, GUILD_ID);
const row = writtenRow(createAuditLog);
expect(row.action_type).toBe(AuditLogActionType.CHANNEL_OVERWRITE_CREATE);
expect(writtenOptions(row)).toEqual({
id: ROLE_ID.toString(),
type: '0',
channel_id: CHANNEL_ID.toString(),
role_name: 'Moderators',
});
});
it('records role_name on update and delete', async () => {
const {service, createAuditLog} = createService(new Map([[ROLE_ID.toString(), 'Moderators']]));
await service.recordPermissionOverwriteDiff({
guildId: GUILD_ID,
userId: ACTOR_ID,
channelId: CHANNEL_ID,
previous: overwrites([[ROLE_ID, {type: 0, allow: 1024n, deny: 0n}]]),
next: overwrites([[ROLE_ID, {type: 0, allow: 0n, deny: 1024n}]]),
});
await service.recordPermissionOverwriteDiff({
guildId: GUILD_ID,
userId: ACTOR_ID,
channelId: CHANNEL_ID,
previous: overwrites([[ROLE_ID, {type: 0, allow: 0n, deny: 1024n}]]),
next: null,
});
expect(writtenRow(createAuditLog, 0).action_type).toBe(AuditLogActionType.CHANNEL_OVERWRITE_UPDATE);
expect(writtenOptions(writtenRow(createAuditLog, 0)).role_name).toBe('Moderators');
expect(writtenRow(createAuditLog, 1).action_type).toBe(AuditLogActionType.CHANNEL_OVERWRITE_DELETE);
expect(writtenOptions(writtenRow(createAuditLog, 1)).role_name).toBe('Moderators');
});
it('records the entry without role_name when the role lookup fails', async () => {
const {service, createAuditLog, getRole} = createService(new Map([[ROLE_ID.toString(), 'Moderators']]));
getRole.mockRejectedValueOnce(new Error('no host available'));
await service.recordPermissionOverwriteDiff({
guildId: GUILD_ID,
userId: ACTOR_ID,
channelId: CHANNEL_ID,
previous: null,
next: overwrites([[ROLE_ID, {type: 0, allow: 1024n, deny: 0n}]]),
});
expect(createAuditLog).toHaveBeenCalledTimes(1);
expect(writtenOptions(writtenRow(createAuditLog))).toEqual({
id: ROLE_ID.toString(),
type: '0',
channel_id: CHANNEL_ID.toString(),
});
});
it('omits role_name for a member overwrite', async () => {
const {service, createAuditLog, getRole} = createService(new Map([[MEMBER_ID.toString(), 'Not a role']]));
await service.recordPermissionOverwriteDiff({
guildId: GUILD_ID,
userId: ACTOR_ID,
channelId: CHANNEL_ID,
previous: null,
next: overwrites([[MEMBER_ID, {type: 1, allow: 1024n, deny: 0n}]]),
});
expect(getRole).not.toHaveBeenCalled();
expect(writtenOptions(writtenRow(createAuditLog))).toEqual({
id: MEMBER_ID.toString(),
type: '1',
channel_id: CHANNEL_ID.toString(),
});
});
it('omits role_name for @everyone', async () => {
const {service, createAuditLog, getRole} = createService(new Map([[GUILD_ID.toString(), '@everyone']]));
await service.recordPermissionOverwriteDiff({
guildId: GUILD_ID,
userId: ACTOR_ID,
channelId: CHANNEL_ID,
previous: null,
next: overwrites([[EVERYONE_ROLE_ID, {type: 0, allow: 0n, deny: 1024n}]]),
});
expect(getRole).not.toHaveBeenCalled();
expect(writtenOptions(writtenRow(createAuditLog))).not.toHaveProperty('role_name');
});
it('omits role_name when the role no longer exists', async () => {
const {service, createAuditLog, getRole} = createService();
await service.recordPermissionOverwriteDiff({
guildId: GUILD_ID,
userId: ACTOR_ID,
channelId: CHANNEL_ID,
previous: overwrites([[ROLE_ID, {type: 0, allow: 1024n, deny: 0n}]]),
next: null,
});
expect(getRole).toHaveBeenCalledTimes(1);
expect(writtenOptions(writtenRow(createAuditLog))).not.toHaveProperty('role_name');
});
it('passes the reason through', async () => {
const {service, createAuditLog} = createService(new Map([[ROLE_ID.toString(), 'Moderators']]));
await service.recordPermissionOverwriteDiff({
guildId: GUILD_ID,
userId: ACTOR_ID,
channelId: CHANNEL_ID,
previous: null,
next: overwrites([[ROLE_ID, {type: 0, allow: 1024n, deny: 0n}]]),
reason: 'channel cleanup',
});
expect(writtenRow(createAuditLog).reason).toBe('channel cleanup');
});
it('writes nothing for an unchanged overwrite', async () => {
const {service, createAuditLog, getRole, dispatchGuild} = createService(
new Map([[ROLE_ID.toString(), 'Moderators']]),
);
await service.recordPermissionOverwriteDiff({
guildId: GUILD_ID,
userId: ACTOR_ID,
channelId: CHANNEL_ID,
previous: overwrites([[ROLE_ID, {type: 0, allow: 1024n, deny: 0n}]]),
next: overwrites([[ROLE_ID, {type: 0, allow: 1024n, deny: 0n}]]),
reason: 'no change',
});
expect(getRole).not.toHaveBeenCalled();
expect(createAuditLog).not.toHaveBeenCalled();
expect(dispatchGuild).not.toHaveBeenCalled();
});
});
@@ -213,9 +213,10 @@ describe('Guild audit log endpoint', () => {
test('includes target users for user-target audit log entries', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
const banReason = 'Audit log user list check';
await createBuilder(harness, owner.token)
.put(`/guilds/${guild.id}/bans/${member.userId}`)
.body({reason: 'Audit log user list check'})
.body({reason: banReason})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const response = await createBuilder<AuditLogResponse>(harness, owner.token)
@@ -224,6 +225,8 @@ describe('Guild audit log endpoint', () => {
const userIds = response.users.map((user) => user.id);
expect(userIds).toContain(owner.userId);
expect(userIds).toContain(member.userId);
const entry = response.audit_log_entries.find((log) => log.target_id === member.userId);
expect(entry?.reason).toBe(banReason);
});
test('fills a page from behind a long run of batched message deletes', async () => {
const {owner, guild, channels} = await setupTestGuildWithMembers(harness, 0);
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
import {createGuild, updateGuild} from '@app/api/guild/tests/GuildTestUtils';
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import type {
GuildEmojiMetadataResponse,
GuildEmojiWithUserResponse,
GuildStickerMetadataResponse,
GuildStickerWithUserResponse,
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
const SOURCE_GUILD_KEYS = ['features', 'icon', 'id', 'name'];
interface MetadataSource {
owner: TestAccount;
guild: GuildResponse;
emoji: GuildEmojiWithUserResponse;
sticker: GuildStickerWithUserResponse;
}
async function createSource(harness: ApiTestHarness, name: string): Promise<MetadataSource> {
const owner = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
const guild = await createGuild(harness, owner.token, name);
const emoji = await createBuilder<GuildEmojiWithUserResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/emojis`)
.body({name: 'source_emoji', image: getPngDataUrl()})
.execute();
const sticker = await createBuilder<GuildStickerWithUserResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/stickers`)
.body({name: 'source_sticker', description: 'source sticker', tags: [], image: getPngDataUrl()})
.execute();
return {owner, guild, emoji, sticker};
}
async function grantGuildFeatures(harness: ApiTestHarness, guildId: string, features: Array<string>): Promise<void> {
await createBuilder(harness, '').post(`/test/guilds/${guildId}/features`).body({add_features: features}).execute();
}
async function getEmojiMetadata(
harness: ApiTestHarness,
token: string,
emojiId: string,
): Promise<GuildEmojiMetadataResponse> {
return createBuilder<GuildEmojiMetadataResponse>(harness, token)
.get(`/emojis/${emojiId}/metadata`)
.expect(HTTP_STATUS.OK)
.execute();
}
async function getStickerMetadata(
harness: ApiTestHarness,
token: string,
stickerId: string,
): Promise<GuildStickerMetadataResponse> {
return createBuilder<GuildStickerMetadataResponse>(harness, token)
.get(`/stickers/${stickerId}/metadata`)
.expect(HTTP_STATUS.OK)
.execute();
}
describe('Guild expression metadata source community', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
test('returns the source community presentation for both kinds to a member and a non-member alike', async () => {
const source = await createSource(harness, 'Metadata Presentation Source');
const withIcon = await updateGuild(harness, source.owner.token, source.guild.id, {icon: getPngDataUrl()});
expect(withIcon.icon).toBeTruthy();
await grantGuildFeatures(harness, source.guild.id, [GuildFeatures.DISCOVERABLE, GuildFeatures.VERIFIED]);
const outsider = await createTestAccount(harness);
const responses = [
await getEmojiMetadata(harness, source.owner.token, source.emoji.id),
await getEmojiMetadata(harness, outsider.token, source.emoji.id),
await getStickerMetadata(harness, source.owner.token, source.sticker.id),
await getStickerMetadata(harness, outsider.token, source.sticker.id),
];
for (const response of responses) {
expect(response.guild_id).toBe(source.guild.id);
expect(response.guild.id).toBe(source.guild.id);
expect(response.guild.name).toBe('Metadata Presentation Source');
expect(response.guild.icon).toBe(withIcon.icon);
expect(response.guild.features).toContain(GuildFeatures.DISCOVERABLE);
expect(response.guild.features).toContain(GuildFeatures.VERIFIED);
expect(response.allow_cloning).toBe(false);
}
const [memberEmoji, outsiderEmoji, memberSticker, outsiderSticker] = responses;
expect(outsiderEmoji.guild).toEqual(memberEmoji.guild);
expect(outsiderSticker.guild).toEqual(memberSticker.guild);
expect(memberSticker.guild).toEqual(memberEmoji.guild);
});
test('reports no badge features and no icon for a community that has neither', async () => {
const source = await createSource(harness, 'Metadata Plain Source');
const outsider = await createTestAccount(harness);
const emoji = await getEmojiMetadata(harness, outsider.token, source.emoji.id);
const sticker = await getStickerMetadata(harness, outsider.token, source.sticker.id);
for (const response of [emoji, sticker]) {
expect(response.guild.icon).toBeNull();
expect(response.guild.features).toEqual([]);
}
});
test('exposes only id, name, icon and badge features to a non-member for both kinds', async () => {
const source = await createSource(harness, 'Metadata Envelope Source');
await updateGuild(harness, source.owner.token, source.guild.id, {verification_level: 1});
await grantGuildFeatures(harness, source.guild.id, [
GuildFeatures.DISCOVERABLE,
GuildFeatures.VERIFIED,
GuildFeatures.INVITES_DISABLED,
GuildFeatures.CLONE_EMOJI_ENABLED,
GuildFeatures.CLONE_STICKER_ENABLED,
GuildFeatures.BANNER,
GuildFeatures.INVITE_SPLASH,
]);
const outsider = await createTestAccount(harness);
const emoji = await getEmojiMetadata(harness, outsider.token, source.emoji.id);
const sticker = await getStickerMetadata(harness, outsider.token, source.sticker.id);
for (const response of [emoji, sticker]) {
expect(response.allow_cloning).toBe(true);
expect(Object.keys(response.guild).sort()).toEqual(SOURCE_GUILD_KEYS);
const leaked = response.guild as Record<string, unknown>;
for (const key of [
'banner',
'banner_width',
'banner_height',
'splash',
'splash_width',
'splash_height',
'embed_splash',
'embed_splash_width',
'embed_splash_height',
'splash_card_alignment',
]) {
expect(leaked).not.toHaveProperty(key);
}
expect([...response.guild.features].sort()).toEqual([GuildFeatures.DISCOVERABLE, GuildFeatures.VERIFIED]);
expect(response.guild.features).not.toContain(GuildFeatures.INVITES_DISABLED);
expect(response.guild.features).not.toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
expect(response.guild.features).not.toContain(GuildFeatures.CLONE_STICKER_ENABLED);
expect(response.guild.features).not.toContain(GuildFeatures.BANNER);
}
expect(sticker.guild).toEqual(emoji.guild);
expect(Object.keys(emoji).sort()).toEqual(['allow_cloning', 'animated', 'guild', 'guild_id', 'id', 'name']);
expect(Object.keys(sticker).sort()).toEqual(['allow_cloning', 'animated', 'guild', 'guild_id', 'id', 'name']);
});
});
@@ -13,13 +13,38 @@ 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_EXPRESSION_INFO_CARD_CONFIG,
type ExpressionInfoCardConfig,
} from '@fluxer/schema/src/domains/experiment/ExpressionInfoCardSchemas';
import {
DEFAULT_GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG,
type GuildActivityLogPresentationConfig,
} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
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 GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG_KEY = 'guild_activity_log_presentation_config';
const EXPRESSION_INFO_CARD_CONFIG_KEY = 'expression_info_card_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 +337,235 @@ 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('returns the default guild activity log presentation config when the key is absent', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
await expect(repository.getGuildActivityLogPresentationConfig()).resolves.toEqual(
DEFAULT_GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG,
);
});
it.each([
{name: 'unparseable text', stored: 'not-json'},
{name: 'a json array', stored: '[]'},
{name: 'out-of-range values', stored: '{"rollout_basis_points":99999}'},
{name: 'a target that is not a snowflake', stored: '{"included_user_ids":["nope"]}'},
])('falls back to the default guild activity log presentation config for $name', async ({stored}) => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
await repository.setConfig(GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG_KEY, stored);
await expect(repository.getGuildActivityLogPresentationConfig()).resolves.toEqual(
DEFAULT_GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG,
);
});
it('round-trips a stored guild activity log presentation config', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
const config: GuildActivityLogPresentationConfig = {
...DEFAULT_GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG,
enabled: true,
config_version: 5,
rollout_basis_points: 2500,
rollout_salt: 'guild-activity-log-presentation-v2',
included_user_ids: ['1400000000000000001'],
excluded_user_ids: ['1400000000000000002'],
};
await repository.setGuildActivityLogPresentationConfig(config);
await expect(repository.getGuildActivityLogPresentationConfig()).resolves.toEqual(config);
});
it('returns the default expression info card config when the key is absent', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
await expect(repository.getExpressionInfoCardConfig()).resolves.toEqual(DEFAULT_EXPRESSION_INFO_CARD_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 expression info card config for $name', async ({stored}) => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
await repository.setConfig(EXPRESSION_INFO_CARD_CONFIG_KEY, stored);
await expect(repository.getExpressionInfoCardConfig()).resolves.toEqual(DEFAULT_EXPRESSION_INFO_CARD_CONFIG);
});
it('round-trips a stored expression info card config', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
const config: ExpressionInfoCardConfig = {
...DEFAULT_EXPRESSION_INFO_CARD_CONFIG,
enabled: true,
config_version: 5,
rollout_basis_points: 2500,
rollout_salt: 'expression-info-card-v2',
included_user_ids: ['1400000000000000001'],
excluded_user_ids: ['1400000000000000002'],
};
await repository.setExpressionInfoCardConfig(config);
await expect(repository.getExpressionInfoCardConfig()).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,30 @@ 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 ExpressionInfoCardConfig,
ExpressionInfoCardConfigSchema,
} from '@fluxer/schema/src/domains/experiment/ExpressionInfoCardSchemas';
import {
type GuildActivityLogPresentationConfig,
GuildActivityLogPresentationConfigSchema,
} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
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,
@@ -54,7 +74,12 @@ import {z} from 'zod';
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
const GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG_KEY = 'guild_activity_log_presentation_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 EXPRESSION_INFO_CARD_CONFIG_KEY = 'expression_info_card_config';
const REGISTRATION_CONFIG_KEY = 'registration_config';
const REGISTRATION_URLS_KEY = 'registration_urls';
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
@@ -337,7 +362,12 @@ type StoredConfigSection =
| 'app public'
| 'gateway rollout'
| 'voice noise suppression'
| 'guild activity log presentation'
| 'experiment delivery'
| 'message hover tracking'
| 'message keyboard focus'
| 'blocked message groups'
| 'expression info card'
| 'instance policy'
| 'integrations'
| 'media'
@@ -475,10 +505,30 @@ function parseStoredVoiceNoiseSuppressionConfig(raw: string | null): VoiceNoiseS
return parseStoredConfigOrDefault(VoiceNoiseSuppressionConfigSchema, raw, 'voice noise suppression');
}
function parseStoredGuildActivityLogPresentationConfig(raw: string | null): GuildActivityLogPresentationConfig {
return parseStoredConfigOrDefault(GuildActivityLogPresentationConfigSchema, raw, 'guild activity log presentation');
}
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
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 parseStoredExpressionInfoCardConfig(raw: string | null): ExpressionInfoCardConfig {
return parseStoredConfigOrDefault(ExpressionInfoCardConfigSchema, raw, 'expression info card');
}
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`);
@@ -997,7 +1047,12 @@ export class InstanceConfigRepository {
parseStoredGatewayRolloutConfig(snapshot.get(GATEWAY_ROLLOUT_CONFIG_KEY) ?? null),
);
parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null);
parseStoredGuildActivityLogPresentationConfig(snapshot.get(GUILD_ACTIVITY_LOG_PRESENTATION_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);
parseStoredExpressionInfoCardConfig(snapshot.get(EXPRESSION_INFO_CARD_CONFIG_KEY) ?? null);
const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
checkStoredConfig('registration', () =>
parseStoredRegistrationConfig(snapshot.get(REGISTRATION_CONFIG_KEY) ?? null),
@@ -1078,11 +1133,65 @@ export class InstanceConfigRepository {
await this.setConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY, JSON.stringify(validated));
}
async getGuildActivityLogPresentationConfig(): Promise<GuildActivityLogPresentationConfig> {
const raw = await this.getConfig(GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG_KEY);
return parseStoredGuildActivityLogPresentationConfig(raw);
}
async setGuildActivityLogPresentationConfig(config: GuildActivityLogPresentationConfig): Promise<void> {
const validated = validateStoredConfig(
GuildActivityLogPresentationConfigSchema,
config,
'guild activity log presentation',
);
await this.setConfig(GUILD_ACTIVITY_LOG_PRESENTATION_CONFIG_KEY, JSON.stringify(validated));
}
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
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 getExpressionInfoCardConfig(): Promise<ExpressionInfoCardConfig> {
const raw = await this.getConfig(EXPRESSION_INFO_CARD_CONFIG_KEY);
return parseStoredExpressionInfoCardConfig(raw);
}
async setExpressionInfoCardConfig(config: ExpressionInfoCardConfig): Promise<void> {
const validated = validateStoredConfig(ExpressionInfoCardConfigSchema, config, 'expression info card');
await this.setConfig(EXPRESSION_INFO_CARD_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));
+99 -9
View File
@@ -24858,9 +24858,10 @@
"allow_cloning": {
"type": "boolean",
"description": "Whether the source guild allows non-members to use the in-app clone shortcut"
}
},
"guild": {"$ref": "#/components/schemas/GuildExpressionSourceGuildResponse"}
},
"required": ["id", "guild_id", "name", "animated", "allow_cloning"],
"required": ["id", "guild_id", "name", "animated", "allow_cloning", "guild"],
"additionalProperties": false
},
"GlobalSearchMessagesRequest": {
@@ -28258,7 +28259,14 @@
"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"},
"guild_activity_log_presentation": {
"$ref": "#/components/schemas/GuildActivityLogPresentationAssignmentResponse"
},
"expression_info_card": {"$ref": "#/components/schemas/ExpressionInfoCardAssignmentResponse"}
},
"additionalProperties": false
}
@@ -28282,9 +28290,10 @@
"allow_cloning": {
"type": "boolean",
"description": "Whether the source guild allows non-members to use the in-app clone shortcut"
}
},
"guild": {"$ref": "#/components/schemas/GuildExpressionSourceGuildResponse"}
},
"required": ["id", "guild_id", "name", "animated", "allow_cloning"],
"required": ["id", "guild_id", "name", "animated", "allow_cloning", "guild"],
"additionalProperties": false
},
"DonationRequestLinkRequest": {
@@ -31996,6 +32005,77 @@
"additionalProperties": false
},
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try"]},
"GuildExpressionSourceGuildResponse": {
"type": "object",
"properties": {
"id": {"description": "The ID of the source guild", "$ref": "#/components/schemas/SnowflakeStringType"},
"name": {"type": "string", "description": "The name of the source guild"},
"icon": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "The hash of the source guild icon"},
"features": {
"type": "array",
"items": {"type": "string", "enum": ["VERIFIED", "PARTNERED", "DISCOVERABLE"]},
"description": "The badge feature flags of the source guild, limited to VERIFIED, PARTNERED, and DISCOVERABLE"
}
},
"required": ["id", "name", "icon", "features"],
"additionalProperties": false,
"description": "Public presentation of the source guild of an expression"
},
"ExpressionInfoCardAssignmentResponse": {
"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
},
"GuildActivityLogPresentationAssignmentResponse": {
"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
},
"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": {
@@ -32292,7 +32372,10 @@
"description": "The ID of the affected entity (user, channel, role, invite code, etc.)",
"anyOf": [{"type": "string"}, {"type": "null"}]
},
"reason": {"description": "The reason provided for the action", "type": "string"},
"reason": {
"description": "The audit log reason. For bans and timeouts without an X-Audit-Log-Reason header this is the reason sent in the request body",
"type": "string"
},
"options": {
"description": "Additional options depending on action type",
"type": "object",
@@ -32300,15 +32383,22 @@
"channel_id": {"description": "Channel ID for relevant actions", "type": "string"},
"count": {"description": "Count of items affected", "type": "number"},
"delete_member_days": {
"description": "Number of days of messages to delete on member ban",
"description": "Deprecated. Whole days of messages deleted, written only by bans recorded before delete_message_seconds",
"type": "string"
},
"delete_message_seconds": {
"description": "Seconds of the banned user's messages that the ban deleted, present only when positive",
"type": "number"
},
"id": {"description": "ID of the affected entity", "type": "string"},
"integration_type": {"description": "Type of integration", "type": "number"},
"message_id": {"description": "Message ID for relevant actions", "type": "string"},
"members_removed": {"description": "Number of members removed", "type": "number"},
"role_name": {"description": "Name of the role", "type": "string"},
"type": {"description": "Type identifier", "type": "number"},
"role_name": {"description": "Name of the role when the entry was written", "type": "string"},
"type": {
"description": "Channel type for CHANNEL_CREATE, CHANNEL_UPDATE and CHANNEL_DELETE. Overwrite target type (0 role, 1 member) for CHANNEL_OVERWRITE_*",
"type": "number"
},
"inviter_id": {"description": "ID of the user who created the invite", "type": "string"},
"max_age": {"description": "Maximum age of the invite in seconds", "type": "number"},
"max_uses": {"description": "Maximum number of uses for the invite", "type": "number"},
@@ -5,6 +5,7 @@ import type {Guild} from '@app/api/models/Guild';
import type {GuildEmoji} from '@app/api/models/GuildEmoji';
import type {GuildSticker} from '@app/api/models/GuildSticker';
import {toIdString, toSortedIdArray} from '@app/api/utils/IdUtils';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
export function serializeGuildForAudit(guild: Guild): Record<string, unknown> {
return {
@@ -49,6 +50,7 @@ export function serializeChannelForAudit(channel: Channel): Record<string, unkno
type: channel.type,
name: channel.name ?? null,
topic: channel.topic ?? null,
...(channel.type === ChannelTypes.GUILD_LINK ? {url: channel.url ?? null} : {}),
parent_id: toIdString(channel.parentId),
position: channel.position,
nsfw: channel.nsfwOverride,
@@ -59,7 +61,6 @@ export function serializeChannelForAudit(channel: Channel): Record<string, unkno
voice_connection_limit: channel.voiceConnectionLimit,
bitrate: channel.bitrate,
rtc_region: channel.rtcRegion ?? null,
permission_overwrite_count: channel.permissionOverwrites ? channel.permissionOverwrites.size : 0,
};
}
+8
View File
@@ -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();
+5
View File
@@ -645,6 +645,11 @@ img::before {
min-height: 1em;
}
[data-emoji-interactive='true'] {
display: inline-block;
cursor: pointer;
}
@media (prefers-contrast: more) {
:root {
--text-primary: hsl(0, 0%, 100%);
@@ -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) => {
@@ -0,0 +1,138 @@
// @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: false};
vi.mock('@app/features/expressions/state/ExpressionInfoCardRollout', () => ({default: rolloutMock}));
vi.mock('@app/features/expressions/utils/CustomEmojiImageUrl', () => ({
buildCustomEmojiURL: ({id, animated}: {id: string; animated: boolean}) =>
`https://cdn.test/emojis/${id}.${animated ? 'gif' : 'png'}`,
}));
vi.mock('@app/features/expressions/utils/EmojiUtils', () => ({getEmojiURL: () => 'https://cdn.test/twemoji.svg'}));
vi.mock('@app/features/expressions/utils/UnicodeEmojis', () => ({
default: {
normalizeEmojiNameToSurrogate: (value: string) => value,
nameForSurrogate: (_surrogate: string, _colons: boolean, fallback: string) => fallback,
},
}));
vi.mock('@app/features/emoji/state/Emoji', () => ({default: {getEmojiById: () => null}}));
vi.mock('@app/features/guild/state/Guilds', () => ({default: {getGuild: () => null}}));
vi.mock('@app/features/ui/state/MobileLayout', () => ({default: {enabled: false}}));
vi.mock('@app/features/app/hooks/useShouldAnimate', () => ({useShouldAnimate: () => true}));
vi.mock('@app/features/presence/hooks/usePresenceCustomStatus', () => ({usePresenceCustomStatus: () => null}));
vi.mock('@app/features/ui/hooks/useTextOverflow', () => ({useTextOverflow: () => false}));
vi.mock('@app/features/theme/layout/RemFromPx', () => ({remFromPx: (value: number) => value}));
vi.mock('@app/features/ui/focus_ring/FocusRing', () => ({
default: ({children}: {children?: ReactNode}) => children,
}));
vi.mock('@lingui/react/macro', () => ({Trans: ({children}: {children?: ReactNode}) => children}));
vi.mock('@phosphor-icons/react', () => ({
PencilIcon: () => createElement('span'),
SmileyIcon: () => createElement('span'),
}));
vi.mock('@app/features/ui/tooltip/Tooltip', () => ({
Tooltip: ({text, children}: {text?: unknown; children?: ReactNode}) =>
createElement('div', {'data-simple-tooltip': String(text ?? '')}, children),
}));
vi.mock('@app/features/ui/tooltip/useHoverFloatingTooltip', () => ({useHoverFloatingTooltip: () => ({})}));
vi.mock('@app/features/ui/tooltip/HoverFloatingTooltipTrigger', () => ({
HoverFloatingTooltipTrigger: ({children}: {children?: ReactNode}) =>
createElement('div', {'data-hover-trigger': 'true'}, children),
}));
vi.mock('@app/features/ui/tooltip/HoverFloatingTooltipSurface', () => ({
HoverFloatingTooltipSurface: ({children}: {children?: ReactNode}) =>
createElement('div', {'data-hover-surface': 'true'}, children),
}));
vi.mock('@app/features/ui/emoji_tooltip_content/EmojiTooltipContent', () => ({
EmojiTooltipContent: ({primaryContent, subtext}: {primaryContent?: ReactNode; subtext?: ReactNode}) =>
createElement('div', {'data-emoji-tooltip-content': String(primaryContent ?? '')}, subtext),
}));
vi.mock('@app/features/emoji/components/emojis/EmojiAttributionSubtext', () => ({
getEmojiAttribution: () => ({type: 'custom_unknown'}),
EmojiAttributionSubtext: ({classes}: {classes?: Record<string, string | undefined>}) =>
createElement('div', {
'data-attribution': 'true',
'data-text-class': String(classes?.text ?? 'absent'),
}),
}));
const {CustomStatusDisplay} = await import(
'@app/features/app/components/shared/custom_status_display/CustomStatusDisplay'
);
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
const STATUS = {text: null, expiresAt: null, emojiId: '4001', emojiName: 'partyblob', emojiAnimated: false};
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
rolloutMock.enabled = false;
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
function render(): void {
act(() => {
root.render(createElement(CustomStatusDisplay, {customStatus: STATUS, showTooltip: true}));
});
}
describe('CustomStatusDisplay status emoji control arm', () => {
it('renders the hovercard with its attribution subtext', () => {
render();
expect(container.querySelector('[data-hover-trigger="true"]')).not.toBeNull();
expect(container.querySelector('[data-hover-surface="true"]')).not.toBeNull();
expect(container.querySelector('[data-emoji-tooltip-content=":partyblob:"]')).not.toBeNull();
expect(container.querySelector('[data-attribution="true"]')).not.toBeNull();
expect(container.querySelector('[data-simple-tooltip]')).toBeNull();
});
it('passes a classes map without a text entry, the way the hovercard always did', () => {
render();
expect(container.querySelector('[data-attribution="true"]')?.getAttribute('data-text-class')).toBe('absent');
});
});
describe('CustomStatusDisplay status emoji experiment arm', () => {
beforeEach(() => {
rolloutMock.enabled = true;
});
it('renders only the name tooltip', () => {
render();
expect(container.querySelector('[data-simple-tooltip=":partyblob:"]')).not.toBeNull();
expect(container.querySelector('[data-hover-trigger="true"]')).toBeNull();
expect(container.querySelector('[data-hover-surface="true"]')).toBeNull();
expect(container.querySelector('[data-attribution="true"]')).toBeNull();
});
});
describe('CustomStatusDisplay status emoji bucket flip', () => {
it('swaps arms without throwing when the assignment changes under a mounted status', () => {
render();
expect(container.querySelector('[data-hover-surface="true"]')).not.toBeNull();
rolloutMock.enabled = true;
act(() => {
root.render(createElement(CustomStatusDisplay, {customStatus: STATUS, showTooltip: true, showText: true}));
});
expect(container.querySelector('[data-hover-surface="true"]')).toBeNull();
expect(container.querySelector('[data-simple-tooltip=":partyblob:"]')).not.toBeNull();
});
});
@@ -7,6 +7,7 @@ import {
getEmojiAttribution,
} from '@app/features/emoji/components/emojis/EmojiAttributionSubtext';
import Emoji from '@app/features/emoji/state/Emoji';
import ExpressionInfoCardRollout from '@app/features/expressions/state/ExpressionInfoCardRollout';
import {buildCustomEmojiURL} from '@app/features/expressions/utils/CustomEmojiImageUrl';
import {getEmojiURL} from '@app/features/expressions/utils/EmojiUtils';
import UnicodeEmojis from '@app/features/expressions/utils/UnicodeEmojis';
@@ -79,6 +80,16 @@ const getTooltipEmojiUrl = (status: CustomStatus, animationAllowed: boolean): st
return null;
};
const getStatusEmojiDisplayName = (status: CustomStatus): string => {
if (status.emojiId) {
return `:${status.emojiName}:`;
}
if (status.emojiName) {
return UnicodeEmojis.nameForSurrogate(status.emojiName, true, status.emojiName);
}
return '';
};
interface StatusEmojiWithTooltipProps {
status: CustomStatus;
children: React.ReactNode;
@@ -86,81 +97,109 @@ interface StatusEmojiWithTooltipProps {
isButton?: boolean;
}
const StatusEmojiWithTooltip = observer(
const StatusEmojiHoverCard = observer(({status, children, onClick, isButton = false}: StatusEmojiWithTooltipProps) => {
const tooltip = useHoverFloatingTooltip(500);
const emoji = status.emojiId ? Emoji.getEmojiById(status.emojiId) : null;
const attribution = getEmojiAttribution({
emojiId: status.emojiId,
guildId: emoji?.guildId ?? null,
guild: emoji?.guildId ? Guilds.getGuild(emoji.guildId) : null,
emojiName: status.emojiName,
});
const emojiName = getStatusEmojiDisplayName(status);
const isAnimatable = getStatusEmojiAnimatable(status);
const animationAllowed = useShouldAnimate({
kind: 'custom_status_emoji',
isAnimated: isAnimatable,
isHovering: isAnimatable,
});
const tooltipEmojiUrl = getTooltipEmojiUrl(status, animationAllowed);
const TriggerComponent = isButton ? 'button' : 'span';
const triggerProps = isButton
? {type: 'button' as const, className: styles.emojiPressable, onClick}
: {className: styles.emojiTooltipTrigger};
return (
<>
<HoverFloatingTooltipTrigger
tooltip={tooltip}
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.hover-floating-tooltip-trigger"
>
<TriggerComponent
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.trigger-component"
{...triggerProps}
>
{children}
</TriggerComponent>
</HoverFloatingTooltipTrigger>
<HoverFloatingTooltipSurface
tooltip={tooltip}
portalDataFlx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.floating-portal"
presenceDataFlx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.animate-presence"
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.div"
>
<EmojiTooltipContent
emojiUrl={tooltipEmojiUrl}
emojiAlt={status.emojiName ?? undefined}
primaryContent={emojiName}
subtext={
<EmojiAttributionSubtext
attribution={attribution}
classes={{
container: styles.emojiTooltipSubtext,
guildRow: styles.emojiTooltipGuildRow,
guildIcon: styles.emojiTooltipGuildIcon,
guildName: styles.emojiTooltipGuildName,
verifiedIcon: styles.emojiTooltipVerifiedIcon,
}}
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.emoji-attribution-subtext"
/>
}
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.emoji-tooltip-content"
/>
</HoverFloatingTooltipSurface>
</>
);
});
const StatusEmojiNameTooltip = observer(
({status, children, onClick, isButton = false}: StatusEmojiWithTooltipProps) => {
const tooltip = useHoverFloatingTooltip(500);
const emoji = status.emojiId ? Emoji.getEmojiById(status.emojiId) : null;
const attribution = getEmojiAttribution({
emojiId: status.emojiId,
guildId: emoji?.guildId ?? null,
guild: emoji?.guildId ? Guilds.getGuild(emoji.guildId) : null,
emojiName: status.emojiName,
});
const getEmojiDisplayName = (): string => {
if (status.emojiId) {
return `:${status.emojiName}:`;
}
if (status.emojiName) {
return UnicodeEmojis.nameForSurrogate(status.emojiName, true, status.emojiName);
}
return '';
};
const emojiName = getEmojiDisplayName();
const isAnimatable = getStatusEmojiAnimatable(status);
const animationAllowed = useShouldAnimate({
kind: 'custom_status_emoji',
isAnimated: isAnimatable,
isHovering: isAnimatable,
});
const tooltipEmojiUrl = getTooltipEmojiUrl(status, animationAllowed);
const TriggerComponent = isButton ? 'button' : 'span';
const triggerProps = isButton
? {type: 'button' as const, className: styles.emojiPressable, onClick}
: {className: styles.emojiTooltipTrigger};
return (
<>
<HoverFloatingTooltipTrigger
tooltip={tooltip}
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.hover-floating-tooltip-trigger"
<Tooltip
text={getStatusEmojiDisplayName(status)}
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.tooltip"
>
<TriggerComponent
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.trigger-component"
{...triggerProps}
>
<TriggerComponent
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.trigger-component"
{...triggerProps}
>
{children}
</TriggerComponent>
</HoverFloatingTooltipTrigger>
<HoverFloatingTooltipSurface
tooltip={tooltip}
portalDataFlx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.floating-portal"
presenceDataFlx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.animate-presence"
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.div"
>
<EmojiTooltipContent
emojiUrl={tooltipEmojiUrl}
emojiAlt={status.emojiName ?? undefined}
primaryContent={emojiName}
subtext={
<EmojiAttributionSubtext
attribution={attribution}
classes={{
container: styles.emojiTooltipSubtext,
guildRow: styles.emojiTooltipGuildRow,
guildIcon: styles.emojiTooltipGuildIcon,
guildName: styles.emojiTooltipGuildName,
verifiedIcon: styles.emojiTooltipVerifiedIcon,
}}
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.emoji-attribution-subtext"
/>
}
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.emoji-tooltip-content"
/>
</HoverFloatingTooltipSurface>
</>
{children}
</TriggerComponent>
</Tooltip>
);
},
);
const StatusEmojiWithTooltip = observer((props: StatusEmojiWithTooltipProps) => {
if (ExpressionInfoCardRollout.enabled) {
return (
<StatusEmojiNameTooltip
{...props}
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.status-emoji-name-tooltip"
/>
);
}
return (
<StatusEmojiHoverCard
{...props}
data-flx="app.custom-status-display.custom-status-display.status-emoji-with-tooltip.status-emoji-hover-card"
/>
);
});
interface EmojiRenderResult {
node: React.ReactNode;
altText: string;
@@ -139,7 +139,7 @@ export function WhatsNewModal({entry}: WhatsNewModalProps) {
>
<SafeMarkdown
content={entry.content}
options={{context: MarkdownContext.STANDARD_WITHOUT_JUMBO}}
options={{context: MarkdownContext.STANDARD_WITHOUT_JUMBO, disableEmojiInfoCard: true}}
data-flx="app.whats-new.whats-new-modal.safe-markdown"
/>
</div>
@@ -21,6 +21,21 @@ const CHANNEL_DELETED_DESCRIPTOR = msg({
message: 'Channel deleted',
comment: 'Audit log entry label. Past-tense action describing that a channel was deleted.',
});
const PERMISSION_OVERRIDE_ADDED_DESCRIPTOR = msg({
message: 'Permission override added',
comment:
'Option in the activity log "Filter by action" dropdown. It shows only entries where a new permission override was added. A permission override is a channel-level exception to the permissions of a role or member. It allows or denies chosen permissions in one channel or category only. It can be for a role, a member or @everyone. Use the same word for override as the channel permissions settings, such as Add override. Keep the word override so it does not read as a permission being granted. Word it as a short label like "Channel created", and keep it parallel with "Permission override updated" and "Permission override removed".',
});
const PERMISSION_OVERRIDE_UPDATED_DESCRIPTOR = msg({
message: 'Permission override updated',
comment:
'Option in the activity log "Filter by action" dropdown. It shows only entries where an existing permission override was changed. A permission override is a channel-level exception to the permissions of a role or member. It allows or denies chosen permissions in one channel or category only. It can be for a role, a member or @everyone. Use the same word for override as the channel permissions settings, such as Add override. Keep the word override so it does not read as a permission being changed. Word it as a short label like "Channel updated", and keep it parallel with "Permission override added" and "Permission override removed".',
});
const PERMISSION_OVERRIDE_REMOVED_DESCRIPTOR = msg({
message: 'Permission override removed',
comment:
'Option in the activity log "Filter by action" dropdown. It shows only entries where a permission override was removed from a channel or category. A permission override is a channel-level exception to the permissions of a role or member. It allows or denies chosen permissions in one channel or category only. It can be for a role, a member or @everyone. Use the same word for override as the channel permissions settings, such as Add override. Keep the word override so it does not read as a permission being taken away. Word it as a short label like "Channel deleted", and keep it parallel with "Permission override added" and "Permission override updated".',
});
const CHANNEL_OVERWRITE_ADDED_DESCRIPTOR = msg({
message: 'Channel overwrite added',
comment: 'Audit log entry label. A channel permission override was added for a role or member.',
@@ -213,6 +228,174 @@ export interface AuditLogActionDefinition {
}
export const AUDIT_LOG_ACTIONS: ReadonlyArray<AuditLogActionDefinition> = [
{
value: AuditLogActionType.GUILD_UPDATE,
label: COMMUNITY_UPDATED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.GUILD,
},
{
value: AuditLogActionType.CHANNEL_CREATE,
label: CHANNEL_CREATED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.CHANNEL,
},
{
value: AuditLogActionType.CHANNEL_UPDATE,
label: CHANNEL_UPDATED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.CHANNEL,
},
{
value: AuditLogActionType.CHANNEL_DELETE,
label: CHANNEL_DELETED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.CHANNEL,
},
{
value: AuditLogActionType.CHANNEL_OVERWRITE_CREATE,
label: PERMISSION_OVERRIDE_ADDED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.CHANNEL,
},
{
value: AuditLogActionType.CHANNEL_OVERWRITE_UPDATE,
label: PERMISSION_OVERRIDE_UPDATED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.CHANNEL,
},
{
value: AuditLogActionType.CHANNEL_OVERWRITE_DELETE,
label: PERMISSION_OVERRIDE_REMOVED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.CHANNEL,
},
{
value: AuditLogActionType.MEMBER_KICK,
label: MEMBER_KICKED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.MEMBER,
},
{
value: AuditLogActionType.MEMBER_BAN_ADD,
label: MEMBER_BANNED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.MEMBER,
},
{
value: AuditLogActionType.MEMBER_BAN_REMOVE,
label: MEMBER_UNBANNED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.MEMBER,
},
{
value: AuditLogActionType.MEMBER_UPDATE,
label: MEMBER_UPDATED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.MEMBER,
},
{
value: AuditLogActionType.MEMBER_ROLE_UPDATE,
label: MEMBER_ROLES_UPDATED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.MEMBER,
},
{
value: AuditLogActionType.MEMBER_MOVE,
label: MEMBER_MOVED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.MEMBER,
},
{
value: AuditLogActionType.MEMBER_DISCONNECT,
label: MEMBER_DISCONNECTED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.MEMBER,
},
{
value: AuditLogActionType.BOT_ADD,
label: BOT_ADDED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.MEMBER,
},
{
value: AuditLogActionType.ROLE_CREATE,
label: ROLE_CREATED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.ROLE,
},
{
value: AuditLogActionType.ROLE_UPDATE,
label: ROLE_UPDATED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.ROLE,
},
{
value: AuditLogActionType.ROLE_DELETE,
label: ROLE_DELETED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.ROLE,
},
{
value: AuditLogActionType.INVITE_CREATE,
label: INVITE_CREATED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.INVITE,
},
{
value: AuditLogActionType.INVITE_DELETE,
label: INVITE_DELETED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.INVITE,
},
{
value: AuditLogActionType.WEBHOOK_CREATE,
label: WEBHOOK_CREATED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.WEBHOOK,
},
{
value: AuditLogActionType.WEBHOOK_UPDATE,
label: WEBHOOK_UPDATED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.WEBHOOK,
},
{
value: AuditLogActionType.WEBHOOK_DELETE,
label: WEBHOOK_DELETED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.WEBHOOK,
},
{
value: AuditLogActionType.EMOJI_CREATE,
label: EMOJI_CREATED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.EMOJI,
},
{
value: AuditLogActionType.EMOJI_UPDATE,
label: EMOJI_UPDATED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.EMOJI,
},
{
value: AuditLogActionType.EMOJI_DELETE,
label: EMOJI_DELETED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.EMOJI,
},
{
value: AuditLogActionType.STICKER_CREATE,
label: STICKER_CREATED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.STICKER,
},
{
value: AuditLogActionType.STICKER_UPDATE,
label: STICKER_UPDATED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.STICKER,
},
{
value: AuditLogActionType.STICKER_DELETE,
label: STICKER_DELETED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.STICKER,
},
{
value: AuditLogActionType.MESSAGE_DELETE,
label: MESSAGE_DELETED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.MESSAGE,
},
{
value: AuditLogActionType.MESSAGE_BULK_DELETE,
label: MESSAGES_DELETED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.MESSAGE,
},
{
value: AuditLogActionType.MESSAGE_PIN,
label: MESSAGE_PINNED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.MESSAGE,
},
{
value: AuditLogActionType.MESSAGE_UNPIN,
label: MESSAGE_UNPINNED_DESCRIPTOR,
targetType: AUDIT_LOG_TARGET_TYPES.MESSAGE,
},
];
export const LEGACY_AUDIT_LOG_ACTIONS: ReadonlyArray<AuditLogActionDefinition> = [
{
value: AuditLogActionType.GUILD_UPDATE,
label: COMMUNITY_UPDATED_DESCRIPTOR,
@@ -390,15 +573,33 @@ export const AUDIT_LOG_ACTIONS: ReadonlyArray<AuditLogActionDefinition> = [
},
];
const translateAuditLogActions = (
i18n: I18n,
actions: ReadonlyArray<AuditLogActionDefinition>,
): Array<{
value: AuditLogActionType;
label: string;
targetType: AuditLogTargetType;
}> =>
actions.map((action) => ({
...action,
label: i18n._(action.label),
}));
export function getTranslatedAuditLogActions(i18n: I18n): Array<{
value: AuditLogActionType;
label: string;
targetType: AuditLogTargetType;
}> {
return AUDIT_LOG_ACTIONS.map((action) => ({
...action,
label: i18n._(action.label),
}));
return translateAuditLogActions(i18n, AUDIT_LOG_ACTIONS);
}
export function getLegacyTranslatedAuditLogActions(i18n: I18n): Array<{
value: AuditLogActionType;
label: string;
targetType: AuditLogTargetType;
}> {
return translateAuditLogActions(i18n, LEGACY_AUDIT_LOG_ACTIONS);
}
export const AUDIT_LOG_TARGET_LABELS: Record<AuditLogTargetType, MessageDescriptor> = {
@@ -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> = [];
@@ -293,6 +293,7 @@ export const EditingMessageInput = observer(
markdown={true}
singleLine={!mobileLayout.enabled}
size="chat"
maxWireLength={maxMessageLength}
className={editingStyles.editor}
autocompleteAnchor={containerRef.current}
ariaLabel={i18n._(EDIT_MESSAGE_DESCRIPTOR)}
@@ -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,
@@ -1353,6 +1356,7 @@ export const LexicalChannelTextareaContent = observer(
channelId={channel.id}
guildId={channel.guildId}
submitOnEnter={!mobileLayout.enabled}
maxWireLength={maxMessageLength}
silentMessagePrefix={!isEditingMessageOnMobile}
focusRingTarget={containerRef}
focusRingEnabled={!textareaInputDisabled && Accessibility.showTextareaFocusRing}
@@ -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,273 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {MessageViewContextProvider} from '@app/features/channel/components/MessageViewContext';
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
import type React from 'react';
import {act, cloneElement, useState} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const matureMedia = vi.hoisted(() => ({blurred: false, canReveal: true, blocked: false}));
const expressionInfoCardRollout = vi.hoisted(() => ({enabled: true}));
vi.mock('@lingui/core/macro', () => {
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
});
vi.mock('@lingui/react/macro', () => ({
Trans: ({children}: {children?: React.ReactNode}) => <span data-flx="test.trans">{children}</span>,
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? '', locale: 'en'}}),
}));
vi.mock('@app/features/expressions/state/ExpressionInfoCardRollout', () => ({default: expressionInfoCardRollout}));
vi.mock('@app/features/messaging/hooks/useMatureMedia', () => ({
useMatureMedia: () => {
const [isRevealed, setIsRevealed] = useState(false);
return {
shouldBlur: matureMedia.blurred && !isRevealed,
shouldBlock: matureMedia.blocked,
canReveal: matureMedia.canReveal,
gateReason: 'none',
reveal: () => {
if (matureMedia.canReveal) setIsRevealed(true);
},
};
},
}));
vi.mock('@app/features/ui/popover/PopoverPopout', () => ({
openPopout: vi.fn(),
Popout: ({
children,
render,
tooltip,
shouldOpenOnClick,
}: {
children: React.ReactElement<{onClick?: (event: React.MouseEvent<HTMLElement>) => void}>;
render?: (props: {popoutKey: number; onClose: () => void}) => React.ReactNode;
tooltip?: string | (() => React.ReactNode);
shouldOpenOnClick?: (event: React.MouseEvent<HTMLElement>) => boolean;
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<span data-test-popout="true" data-flx="test.popout">
{cloneElement(children, {
onClick: (event: React.MouseEvent<HTMLElement>) => {
if (shouldOpenOnClick?.(event) ?? true) setIsOpen(true);
},
})}
<span data-test-popout-tooltip="true" data-flx="test.popout-tooltip">
{typeof tooltip === 'function' ? tooltip() : tooltip}
</span>
{isOpen ? (
<span data-test-popout-card="true" data-flx="test.popout-card">
{render?.({popoutKey: 1, onClose: () => setIsOpen(false)})}
</span>
) : null}
</span>
);
},
}));
vi.mock('@app/features/expressions/components/ExpressionInfoCard', () => ({
ExpressionInfoCard: ({displayName}: {displayName: string}) => (
<span data-test-info-card={displayName} data-flx="test.info-card" />
),
}));
vi.mock('@app/features/expressions/components/bottomsheets/ExpressionInfoBottomSheet', () => ({
ExpressionInfoBottomSheet: ({isOpen}: {isOpen: boolean}) => (
<span data-test-bottom-sheet={String(isOpen)} data-flx="test.bottom-sheet" />
),
}));
vi.mock('@app/features/presence/state/LocalPresence', () => ({
default: {updatePresence: vi.fn()},
setLocalPresenceUserSettings: vi.fn(),
ACCOUNT_PRESENCE_INTENT_MAX_AGE_MS: 60_000,
}));
vi.mock('@app/features/ui/commands/ContextMenuCommands', () => ({openFromEvent: vi.fn()}));
vi.mock('@app/features/ui/action_menu/MessageContextMenu', () => ({MessageContextMenu: () => null}));
vi.mock('@app/features/ui/action_menu/items/StickerContextMenuItems', () => ({StickerInlineMenuItems: () => null}));
vi.mock('@app/features/channel/components/MessageReactions', () => ({MessageReactions: () => null}));
vi.mock('@app/features/channel/components/embeds/ChannelEmbed', () => ({Embed: () => null}));
vi.mock('@app/features/channel/components/embeds/attachments/Attachment', () => ({Attachment: () => null}));
vi.mock('@app/features/channel/components/embeds/attachments/AttachmentMosaic', () => ({AttachmentMosaic: () => null}));
vi.mock('@app/features/channel/components/InviteEmbed', () => ({InviteEmbed: () => null}));
vi.mock('@app/features/channel/components/GiftEmbed', () => ({GiftEmbed: () => null}));
vi.mock('@app/features/channel/components/ThemeEmbed', () => ({ThemeEmbed: () => null}));
vi.mock('@app/features/messaging/components/markdown', () => ({SafeMarkdown: () => null}));
vi.mock('@app/features/channel/components/TimestampWithTooltip', () => ({TimestampWithTooltip: () => null}));
vi.mock('@app/features/ui/components/Avatar', () => ({Avatar: () => null}));
vi.mock('@app/features/app/components/shared/GroupDMAvatar', () => ({GroupDMAvatar: () => null}));
vi.mock('@app/features/app/components/shared/SpoilerOverlay', () => ({SpoilerOverlay: () => null}));
vi.mock('@app/features/messaging/utils/MessageNavigator', () => ({goToMessage: vi.fn()}));
vi.mock('@app/features/messaging/utils/MessageCopyTextUtils', () => ({buildMessageSnapshotCopyText: () => ''}));
installVoiceMenuTestBootstrap();
const {MessageAttachments} = await import('@app/features/channel/components/MessageAttachments');
const STICKER_NAME = 'blobsticker';
const message = {
id: '1',
channelId: '2',
content: '',
attachments: [],
embeds: [],
invites: [],
themes: [],
gifts: [],
stickers: [{id: '20', name: STICKER_NAME, format_type: 1}],
messageSnapshots: null,
suppressEmbeds: false,
};
let host: HTMLDivElement;
let root: Root;
function renderMessage(): void {
act(() => {
root.render(
<MessageViewContextProvider
value={
{
channel: {id: '2'},
message,
shouldGroup: false,
isHovering: false,
messageDisplayCompact: false,
handleDelete: () => {},
} as never
}
data-flx="test.message-view-context-provider"
>
<MessageAttachments data-flx="test.message-attachments" />
</MessageViewContextProvider>,
);
});
}
function stickerButton(): HTMLButtonElement {
const button = host.querySelector<HTMLButtonElement>('button[data-message-sticker="true"]');
if (button == null) throw new Error('sticker trigger is missing');
return button;
}
function clickSticker(): void {
const button = stickerButton();
act(() => {
button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
});
}
function isBlurred(): boolean {
return host.querySelector('img')?.className.includes('matureStickerBlurred') === true;
}
function isCardOpen(): boolean {
return host.querySelector('[data-test-popout-card]') != null;
}
function tooltipText(): string {
return host.querySelector('[data-test-popout-tooltip]')?.textContent ?? '';
}
beforeEach(() => {
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
matureMedia.blurred = false;
matureMedia.canReveal = true;
matureMedia.blocked = false;
expressionInfoCardRollout.enabled = true;
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
afterEach(() => {
act(() => {
root.unmount();
});
host.remove();
});
describe('message stickers', () => {
it('renders the sticker as an info card trigger', () => {
renderMessage();
expect(host.querySelector('[data-test-popout]')).not.toBeNull();
expect(stickerButton().getAttribute('aria-label')).toBe(STICKER_NAME);
expect(isCardOpen()).toBe(false);
});
it('opens the info card on the first activation when nothing is blurred', () => {
renderMessage();
clickSticker();
expect(isCardOpen()).toBe(true);
expect(host.querySelector(`[data-test-info-card="${STICKER_NAME}"]`)).not.toBeNull();
});
it('reveals instead of opening the card while the sticker is blurred', () => {
matureMedia.blurred = true;
renderMessage();
expect(isBlurred()).toBe(true);
clickSticker();
expect(isBlurred()).toBe(false);
expect(isCardOpen()).toBe(false);
});
it('opens the info card once the blurred sticker has been revealed', () => {
matureMedia.blurred = true;
renderMessage();
clickSticker();
clickSticker();
expect(isCardOpen()).toBe(true);
});
it('neither reveals nor opens the card when the blur cannot be revealed', () => {
matureMedia.blurred = true;
matureMedia.canReveal = false;
renderMessage();
clickSticker();
expect(isBlurred()).toBe(true);
expect(isCardOpen()).toBe(false);
});
it('hides the card hint from the hover tooltip while the sticker is blurred', () => {
matureMedia.blurred = true;
renderMessage();
expect(tooltipText()).toBe(STICKER_NAME);
clickSticker();
expect(tooltipText()).toContain(STICKER_NAME);
expect(tooltipText()).toContain('Click to learn more');
});
});
describe('message stickers on the control arm', () => {
beforeEach(() => {
expressionInfoCardRollout.enabled = false;
});
it('renders the sticker inside the plain tooltip with no info card trigger', () => {
renderMessage();
expect(host.querySelector('[data-test-popout]')).toBeNull();
expect(stickerButton().getAttribute('aria-label')).toBe(STICKER_NAME);
expect(host.querySelector('[data-test-info-card]')).toBeNull();
});
it('opens no info card when the sticker is activated', () => {
renderMessage();
clickSticker();
expect(isCardOpen()).toBe(false);
expect(host.querySelector('[data-test-info-card]')).toBeNull();
});
it('still reveals a blurred sticker on activation', () => {
matureMedia.blurred = true;
renderMessage();
expect(isBlurred()).toBe(true);
clickSticker();
expect(isBlurred()).toBe(false);
expect(isCardOpen()).toBe(false);
});
it('keeps the context menu attributes on the trigger', () => {
renderMessage();
expect(stickerButton().getAttribute('data-message-sticker')).toBe('true');
});
});
@@ -16,6 +16,15 @@ import {TimestampWithTooltip} from '@app/features/channel/components/TimestampWi
import type {Channel} from '@app/features/channel/models/Channel';
import {useStickerAnimation} from '@app/features/emoji/hooks/useStickerAnimation';
import Sticker from '@app/features/emoji/state/EmojiSticker';
import {ExpressionInfoBottomSheet} from '@app/features/expressions/components/bottomsheets/ExpressionInfoBottomSheet';
import {ExpressionHoverTooltipContent} from '@app/features/expressions/components/ExpressionHoverTooltipContent';
import {ExpressionInfoCard} from '@app/features/expressions/components/ExpressionInfoCard';
import {ExpressionInfoPopout} from '@app/features/expressions/components/ExpressionInfoPopout';
import ExpressionInfoCardRollout from '@app/features/expressions/state/ExpressionInfoCardRollout';
import {
EXPRESSION_INFO_SURFACE_OPEN_IS_INTERACTION,
STICKER_PREVIEW_SIZE,
} from '@app/features/expressions/utils/ExpressionPreviewConstants';
import * as GiftCodeUtils from '@app/features/gift/utils/GiftCodeUtils';
import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon';
import Guilds from '@app/features/guild/state/Guilds';
@@ -39,6 +48,7 @@ import {MessageContextMenu} from '@app/features/ui/action_menu/MessageContextMen
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
import {Avatar} from '@app/features/ui/components/Avatar';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import MobileLayout from '@app/features/ui/state/MobileLayout';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import UserSettings from '@app/features/user/state/UserSettings';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
@@ -54,7 +64,7 @@ import {ArrowBendUpRightIcon, CaretRightIcon, HashIcon, NotePencilIcon, SpeakerH
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useMemo} from 'react';
import {useCallback, useMemo, useState} from 'react';
interface SpoileredCodeLinkMatch {
code: string;
@@ -461,7 +471,7 @@ interface StickerItemProps {
handleDelete?: (bypassConfirm?: boolean) => void;
}
const StickerItem = observer(({sticker, message, sourceChannel, handleDelete}: StickerItemProps) => {
const StickerItemWithTooltip = observer(({sticker, message, sourceChannel, handleDelete}: StickerItemProps) => {
const {shouldAnimate, interactionHandlers} = useStickerAnimation({isAnimated: sticker.animated});
const stickerUrl = AvatarUtils.getStickerURL({
id: sticker.id,
@@ -572,6 +582,171 @@ const StickerItem = observer(({sticker, message, sourceChannel, handleDelete}: S
</Tooltip>
);
});
const StickerItemWithInfoCard = observer(({sticker, message, sourceChannel, handleDelete}: StickerItemProps) => {
const {shouldAnimate, interactionHandlers} = useStickerAnimation({isAnimated: sticker.animated});
const {shouldAnimate: shouldAnimateInfoPreview} = useStickerAnimation({
isAnimated: sticker.animated,
isInteracting: EXPRESSION_INFO_SURFACE_OPEN_IS_INTERACTION,
});
const stickerUrl = AvatarUtils.getStickerURL({
id: sticker.id,
animated: shouldAnimate,
isAnimatable: sticker.animated,
size: 320,
});
const previewUrl = AvatarUtils.getStickerURL({
id: sticker.id,
animated: shouldAnimateInfoPreview,
isAnimatable: sticker.animated,
size: STICKER_PREVIEW_SIZE,
});
const stickerRecord = Sticker.getStickerById(sticker.id);
const isMobile = MobileLayout.enabled;
const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false);
const {shouldBlur, shouldBlock, canReveal, reveal} = useMatureMedia(false, message.channelId);
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const stickerForMenu = stickerRecord ?? {
id: sticker.id,
guildId: '',
name: sticker.name,
description: '',
tags: [],
url: stickerUrl,
animated: sticker.animated,
user: undefined,
};
ContextMenuCommands.openFromEvent(e, ({onClose}) => (
<MessageContextMenu
message={message}
sourceChannel={sourceChannel}
onClose={onClose}
onDelete={handleDelete!}
inlineStickerOrEmojiItems={
<StickerInlineMenuItems
sticker={stickerForMenu}
onClose={onClose}
data-flx="channel.message-attachments.handle-context-menu.sticker-inline-menu-items"
/>
}
data-flx="channel.message-attachments.handle-context-menu.message-context-menu"
/>
));
};
const handleRevealClick = useCallback(
(e: React.MouseEvent) => {
if (shouldBlur && canReveal) {
e.preventDefault();
e.stopPropagation();
reveal();
}
},
[shouldBlur, canReveal, reveal],
);
const handleMobileClick = useCallback(
(e: React.MouseEvent) => {
if (shouldBlur) {
handleRevealClick(e);
return;
}
setIsBottomSheetOpen(true);
},
[shouldBlur, handleRevealClick],
);
const handleCloseBottomSheet = useCallback(() => {
setIsBottomSheetOpen(false);
}, []);
if (shouldBlock) {
return null;
}
const stickerImage = (
<img
src={stickerUrl}
alt={stickerRecord?.description || sticker.name}
className={clsx(styles.stickerImage, shouldBlur && matureStyles.matureStickerBlurred)}
width="160"
height="160"
data-flx="channel.message-attachments.sticker-item.sticker-image"
/>
);
if (isMobile) {
return (
<>
<FocusRing data-flx="channel.message-attachments.sticker-item.focus-ring.mobile">
<button
type="button"
aria-label={stickerRecord?.description || sticker.name}
className={styles.stickerWrapper}
data-message-sticker="true"
onContextMenu={handleContextMenu}
onClick={handleMobileClick}
data-flx="channel.message-attachments.sticker-item.sticker-wrapper.open-bottom-sheet"
{...interactionHandlers}
>
{stickerImage}
</button>
</FocusRing>
<ExpressionInfoBottomSheet
kind="sticker"
isOpen={isBottomSheetOpen}
onClose={handleCloseBottomSheet}
sticker={{id: sticker.id, name: sticker.name, animated: sticker.animated}}
data-flx="channel.message-attachments.sticker-item.expression-info-bottom-sheet"
/>
</>
);
}
const renderHoverTooltip = () =>
shouldBlur ? (
sticker.name
) : (
<ExpressionHoverTooltipContent
displayName={sticker.name}
previewUrl={previewUrl}
data-flx="channel.message-attachments.sticker-item.expression-hover-tooltip-content"
/>
);
const renderInfoCard = ({onClose}: {onClose: () => void}) => (
<ExpressionInfoCard
kind="sticker"
expressionId={sticker.id}
guildId={stickerRecord?.guildId ?? null}
displayName={sticker.name}
previewUrl={previewUrl}
onClose={onClose}
data-flx="channel.message-attachments.sticker-item.expression-info-card"
/>
);
return (
<ExpressionInfoPopout
canOpenCard={!shouldBlur}
renderTooltip={renderHoverTooltip}
renderCard={renderInfoCard}
data-flx="channel.message-attachments.sticker-item.expression-info-popout"
>
<button
type="button"
aria-label={stickerRecord?.description || sticker.name}
className={styles.stickerWrapper}
data-message-sticker="true"
onContextMenu={handleContextMenu}
onClick={handleRevealClick}
data-flx="channel.message-attachments.sticker-item.sticker-wrapper.reveal-click"
{...interactionHandlers}
>
{stickerImage}
</button>
</ExpressionInfoPopout>
);
});
const StickerItem = observer((props: StickerItemProps) => {
if (!ExpressionInfoCardRollout.enabled) {
return <StickerItemWithTooltip {...props} data-flx="channel.message-attachments.sticker-item.tooltip-arm" />;
}
return <StickerItemWithInfoCard {...props} data-flx="channel.message-attachments.sticker-item.info-card-arm" />;
});
export const MessageAttachments = observer(() => {
const {channel, message, handleDelete, previewContext, onPopoutToggle, suppressMessageActions} =
useMessageViewContext();
@@ -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;
}
@@ -0,0 +1,146 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
import type React from 'react';
import {act} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const expressionInfoCardRollout = vi.hoisted(() => ({enabled: true}));
vi.mock('@app/features/expressions/state/ExpressionInfoCardRollout', () => ({default: expressionInfoCardRollout}));
vi.mock('@lingui/core/macro', () => {
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
return {msg: descriptor, plural: () => '', t: descriptor};
});
vi.mock('@lingui/react/macro', () => ({
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? '', locale: 'en'}}),
}));
vi.mock('@app/features/app/components/LongPressable', () => ({
LongPressable: ({children, onLongPress}: {children?: React.ReactNode; onLongPress?: () => void}) => (
<div data-flx="test.long-pressable">
<button type="button" data-test-long-press="true" onClick={onLongPress} data-flx="test.long-press">
{'press'}
</button>
{children}
</div>
),
}));
vi.mock('@app/features/emoji/components/bottomsheets/EmojiInfoBottomSheet', () => ({
EmojiInfoBottomSheet: ({isOpen}: {isOpen: boolean}) => (
<span data-test-emoji-sheet={String(isOpen)} data-flx="test.emoji-sheet" />
),
}));
vi.mock('@app/features/expressions/components/bottomsheets/ExpressionInfoBottomSheet', () => ({
ExpressionInfoBottomSheet: ({isOpen}: {isOpen: boolean}) => (
<span data-test-expression-sheet={String(isOpen)} data-flx="test.expression-sheet" />
),
}));
vi.mock('@app/features/ui/state/MobileLayout', () => ({default: {isMobileLayout: () => true, enabled: true}}));
vi.mock('@app/features/channel/components/MessageActionUtils', () => ({
createMessageActionHandlers: () => ({handleEmojiSelect: vi.fn()}),
isClientSystemMessage: () => false,
useMessagePermissions: () => ({canAddReactions: false, channel: null}),
}));
vi.mock('@app/features/messaging/hooks/useMessageReactionStore', () => ({
useMessageReactions: () => [{emoji: {id: '30', name: 'blob', animated: false}, count: 1, me: false}],
}));
vi.mock('@app/features/messaging/hooks/useMatureMedia', () => ({
useMatureMedia: () => ({shouldBlur: false, shouldBlock: false, canReveal: true, gateReason: 'none', reveal: vi.fn()}),
}));
vi.mock('@app/features/messaging/utils/ReactionUtils', () => ({
getEmojiName: () => ':blob:',
getReactionKey: () => 'reaction-key',
useEmojiURL: () => 'https://cdn.test/blob.webp',
}));
vi.mock('@app/features/messaging/components/ReactionImage', () => ({ReactionImage: () => null}));
vi.mock('@app/features/messaging/components/popouts/ReactionTooltip', () => ({
ReactionTooltip: ({children}: {children?: React.ReactNode}) => (
<span data-flx="test.reaction-tooltip">{children}</span>
),
}));
vi.mock('@app/features/emoji/components/popouts/EmojiPickerPopout', () => ({EmojiPickerPopout: () => null}));
vi.mock('@app/features/expressions/components/modals/ExpressionPickerSheet', () => ({
ExpressionPickerSheet: () => null,
}));
vi.mock('@app/features/ui/popover/PopoverPopout', () => ({Popout: () => null, openPopout: vi.fn()}));
vi.mock('@app/features/emoji/state/Emoji', () => ({default: {getEmojiById: () => null}}));
vi.mock('@app/features/ui/commands/ContextMenuCommands', () => ({openFromEvent: vi.fn()}));
vi.mock('@app/features/ui/action_menu/items/EmojiContextMenuItems', () => ({EmojiContextMenuItems: () => null}));
vi.mock('@app/features/accessibility/state/Accessibility', () => ({default: {useReducedMotion: true}}));
vi.mock('@app/features/ui/state/KeyboardMode', () => ({default: {keyboardModeEnabled: false}}));
installVoiceMenuTestBootstrap();
const {MessageReactions} = await import('@app/features/channel/components/MessageReactions');
const message = {id: '1', channelId: '2'} as never;
let host: HTMLDivElement;
let root: Root;
function renderReactions(): void {
act(() => {
root.render(<MessageReactions message={message} onPopoutToggle={() => {}} data-flx="test.message-reactions" />);
});
}
function longPress(): void {
const trigger = host.querySelector<HTMLButtonElement>('[data-test-long-press="true"]');
if (trigger == null) throw new Error('long press trigger is missing');
act(() => {
trigger.dispatchEvent(new MouseEvent('click', {bubbles: true}));
});
}
beforeEach(() => {
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
expressionInfoCardRollout.enabled = true;
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
afterEach(() => {
act(() => {
root.unmount();
});
host.remove();
});
describe('reaction long press on the experiment arm', () => {
it('opens the expression info sheet and never the old emoji sheet', () => {
renderReactions();
expect(host.querySelector('[data-test-emoji-sheet]')).toBeNull();
longPress();
expect(host.querySelector('[data-test-expression-sheet="true"]')).not.toBeNull();
expect(host.querySelector('[data-test-emoji-sheet]')).toBeNull();
});
});
describe('reaction long press on the control arm', () => {
beforeEach(() => {
expressionInfoCardRollout.enabled = false;
});
it('opens the emoji info sheet and never the expression sheet', () => {
renderReactions();
expect(host.querySelector('[data-test-expression-sheet]')).toBeNull();
longPress();
expect(host.querySelector('[data-test-emoji-sheet="true"]')).not.toBeNull();
expect(host.querySelector('[data-test-expression-sheet]')).toBeNull();
});
});
describe('reaction long press when the bucket flips while the sheet is open', () => {
it('swaps the open sheet without crashing', () => {
renderReactions();
longPress();
expect(host.querySelector('[data-test-expression-sheet="true"]')).not.toBeNull();
expressionInfoCardRollout.enabled = false;
renderReactions();
expect(host.querySelector('[data-test-emoji-sheet="true"]')).not.toBeNull();
expect(host.querySelector('[data-test-expression-sheet]')).toBeNull();
});
});
@@ -13,7 +13,9 @@ import {EmojiInfoBottomSheet} from '@app/features/emoji/components/bottomsheets/
import {EmojiPickerPopout} from '@app/features/emoji/components/popouts/EmojiPickerPopout';
import Emoji from '@app/features/emoji/state/Emoji';
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
import {ExpressionInfoBottomSheet} from '@app/features/expressions/components/bottomsheets/ExpressionInfoBottomSheet';
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
import ExpressionInfoCardRollout from '@app/features/expressions/state/ExpressionInfoCardRollout';
import {ADD_REACTION_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
import * as ReactionCommands from '@app/features/messaging/commands/ReactionCommands';
@@ -83,6 +85,7 @@ const MessageReactionItem = observer(
const [selectedEmoji, setSelectedEmoji] = useState<EmojiInfoData | null>(null);
const [tooltipHovering, setTooltipHovering] = useState(false);
const isMobile = MobileLayout.isMobileLayout();
const expressionInfoCardEnabled = ExpressionInfoCardRollout.enabled;
useEffect(() => {
if (prevCount !== reaction.count) {
setPrevCount(reaction.count);
@@ -231,12 +234,22 @@ const MessageReactionItem = observer(
data-flx="channel.message-reactions.message-reaction-item.long-pressable"
>
{buttonContent}
<EmojiInfoBottomSheet
isOpen={emojiInfoOpen}
onClose={handleCloseEmojiInfo}
emoji={selectedEmoji}
data-flx="channel.message-reactions.message-reaction-item.emoji-info-bottom-sheet"
/>
{expressionInfoCardEnabled ? (
<ExpressionInfoBottomSheet
kind="emoji"
isOpen={emojiInfoOpen}
onClose={handleCloseEmojiInfo}
emoji={selectedEmoji}
data-flx="channel.message-reactions.message-reaction-item.expression-info-bottom-sheet"
/>
) : (
<EmojiInfoBottomSheet
isOpen={emojiInfoOpen}
onClose={handleCloseEmojiInfo}
emoji={selectedEmoji}
data-flx="channel.message-reactions.message-reaction-item.emoji-info-bottom-sheet"
/>
)}
</LongPressable>
);
}
@@ -3,6 +3,7 @@
import Authentication from '@app/features/auth/state/Authentication';
import {useCollapsedMessageVisibility} from '@app/features/channel/components/CollapsedMessageVisibilityContext';
import {PreloadableUserPopout} from '@app/features/channel/components/PreloadableUserPopout';
import ExpressionInfoCardRollout from '@app/features/expressions/state/ExpressionInfoCardRollout';
import GuildMembers from '@app/features/member/state/GuildMembers';
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
@@ -251,6 +252,7 @@ export const ReplyPreview = observer(
context: MarkdownContext.RESTRICTED_INLINE_REPLY,
messageId: referencedMessage.id,
channelId,
disableInteractions: ExpressionInfoCardRollout.enabled,
mentionChannels: referencedMessage.mentionChannels,
}}
data-flx="channel.reply-preview.safe-markdown"
@@ -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}
@@ -64,6 +64,7 @@ export const ChannelTopicSection: React.FC<ChannelTopicSectionProps> = ({channel
options={{
context: MarkdownContext.RESTRICTED_INLINE_REPLY,
channelId,
disableEmojiInfoCard: true,
}}
data-flx="channel.channel-details-bottom-sheet.safe-markdown"
/>
@@ -17,6 +17,7 @@ import {EditGroupBottomSheet} from '@app/features/channel/components/modals/Edit
import {GroupInvitesBottomSheet} from '@app/features/channel/components/modals/GroupInvitesBottomSheet';
import type {Channel} from '@app/features/channel/models/Channel';
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
import ExpressionInfoCardRollout from '@app/features/expressions/state/ExpressionInfoCardRollout';
import {useLeaveGroup} from '@app/features/guild/hooks/useLeaveGroup';
import Guilds from '@app/features/guild/state/Guilds';
import {MENTION_COUNT_ARIA_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
@@ -273,6 +274,7 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
channelId: channel.id,
messageId: lastMessage.id,
disableAnimatedEmoji: true,
disableInteractions: ExpressionInfoCardRollout.enabled,
mentionChannels: lastMessage.mentionChannels,
}}
data-flx="channel.direct-message.dm-list-item.get-message-preview.safe-markdown"
@@ -72,7 +72,12 @@ export const EmbedAuthorComponent: FC<{author?: EmbedAuthor}> = observer(({autho
export const EmbedTitle: FC<{title?: string; url?: string; messageId?: string; channelId?: string}> = observer(
({title, url, messageId, channelId}) => {
if (title == null || title.length === 0) return null;
const options = {context: MarkdownContext.RESTRICTED_INLINE_REPLY, messageId, channelId};
const options = {
context: MarkdownContext.RESTRICTED_INLINE_REPLY,
messageId,
channelId,
disableEmojiInteractions: Boolean(url),
};
return (
<div className={styles.embedTitle} data-flx="channel.embeds.embed.embed-title.embed-title">
{url ? (
@@ -26,6 +26,7 @@ export const ChannelTopicModal = observer(({channelId}: ChannelTopicModalProps)
options={{
context: MarkdownContext.STANDARD_WITHOUT_JUMBO,
channelId,
disableEmojiInfoCard: true,
}}
data-flx="channel.channel-topic-modal.safe-markdown"
/>
@@ -157,7 +157,7 @@ export const ChannelOverviewTopicEditor = observer(
});
}, [actualTopic, form, isTopicInitialized]);
const handleTopicChange = useCallback((_display: string, _segments: Array<MentionSegment>, wire: string) => {
setActualTopic(dropTrailingEmptyBlockquoteLines(wire));
setActualTopic(dropTrailingEmptyBlockquoteLines(wire, TOPIC_MARKDOWN_PARSER_FLAGS));
}, []);
const handleTopicEmojiSelect = useCallback((emoji: FlatEmoji, shiftKey: boolean) => {
const composer = composerRef.current;
@@ -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);
});
});
@@ -6,8 +6,7 @@ import Emoji from '@app/features/emoji/state/Emoji';
import type {GuildEmoji} from '@app/features/expressions/models/GuildEmoji';
import GuildGuilds from '@app/features/guild/state/Guilds';
import {
$insertComposerClipboardSlice,
type ComposerClipboardSlice,
$insertComposerPastedText,
FLUXER_COMPOSER_CLIPBOARD_MIME,
getComposerClipboardTextPlain,
parseComposerClipboardSlice,
@@ -23,7 +22,6 @@ import {canAttachFilesInChannel} from '@app/features/messaging/utils/AttachmentP
import {getClipboardDataFiles, readClipboardImageFiles} from '@app/features/messaging/utils/ClipboardFilePasteUtils';
import * as FileUploadUtils from '@app/features/messaging/utils/FileUploadUtils';
import {detectPastedSegments, type LookupFunctions} from '@app/features/messaging/utils/PasteSegmentUtils';
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
import {isDialogPasteTarget} from '@app/features/messaging/utils/TextInputEditUtils';
import {canFocusTextarea, safeFocus} from '@app/features/platform/utils/InputFocusManager';
import QuickSwitcher from '@app/features/search/state/QuickSwitcher';
@@ -47,27 +45,6 @@ interface UseChannelComposerPasteParams {
textareaInputDisabled: boolean;
}
function createComposerPasteSlice(pastedText: string, segments: ReadonlyArray<MentionSegment>): ComposerClipboardSlice {
const displayParts: Array<string> = [];
const projectedSegments: Array<MentionSegment> = [];
let sourceCursor = 0;
let displayLength = 0;
for (const segment of segments) {
const plainText = pastedText.slice(sourceCursor, segment.start);
displayParts.push(plainText, segment.displayText);
displayLength += plainText.length;
projectedSegments.push({
...segment,
start: displayLength,
end: displayLength + segment.displayText.length,
});
displayLength += segment.displayText.length;
sourceCursor = segment.end;
}
displayParts.push(pastedText.slice(sourceCursor));
return {display: displayParts.join(''), segments: projectedSegments};
}
export function useChannelComposerPaste({
channel,
handleRef,
@@ -173,13 +150,14 @@ export function useChannelComposerPaste({
return null;
},
};
const segments = detectPastedSegments(pastedText, 0, lookups);
const richSlice = createComposerPasteSlice(pastedText, segments);
const plainText = getComposerClipboardTextPlain(richSlice);
const slice = plainText === null || plainText === undefined ? {display: pastedText, segments: []} : richSlice;
return $insertComposerClipboardSlice(slice, ChatInputSettings.renderComposerAsPlainText);
return $insertComposerPastedText(
pastedText,
detectPastedSegments(pastedText, 0, lookups),
ChatInputSettings.renderComposerAsPlainText,
handleRef.current,
);
},
[channel.guildId],
[channel.guildId, handleRef],
);
useEffect(() => {
const handle = handleRef.current;
@@ -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;
@@ -38,7 +38,7 @@ export interface EmojiAttributionSource {
const getIsVerified = (guild?: EmojiGuild | null): boolean => {
if (!guild) return false;
const features = (guild as Guild).features ?? (guild as Guild).features;
const features = guild.features;
if (!features) return false;
if (Array.isArray(features)) {
return features.includes('VERIFIED');
@@ -108,17 +108,10 @@ export const EmojiAttributionSubtext = observer(function EmojiAttributionSubtext
</div>
);
}
const guildName = attribution.guild.name;
return (
<div className={classes.container} data-flx="emoji.emojis.emoji-attribution-subtext.div--4">
<span className={classes.text} data-flx="emoji.emojis.emoji-attribution-subtext.span--4">
<Trans comment="Emoji attribution line under a custom emoji. guildName is the community it comes from.">
This is a custom emoji from{' '}
<span className={classes.guildName} data-flx="emoji.emojis.emoji-attribution-subtext.span--5">
{guildName}
</span>
.
</Trans>
<Trans>This is a custom emoji from</Trans>
</span>
<div className={classes.guildRow} data-flx="emoji.emojis.emoji-attribution-subtext.div--5">
<div className={classes.guildIcon} data-flx="emoji.emojis.emoji-attribution-subtext.div--6">
@@ -130,6 +123,9 @@ export const EmojiAttributionSubtext = observer(function EmojiAttributionSubtext
data-flx="emoji.emojis.emoji-attribution-subtext.guild-icon"
/>
</div>
<span className={classes.guildName} data-flx="emoji.emojis.emoji-attribution-subtext.span--5">
{attribution.guild.name}
</span>
{attribution.isVerified && (
<Tooltip
text={i18n._(VERIFIED_COMMUNITY_DESCRIPTOR)}
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {afterEach, describe, expect, it, vi} from 'vitest';
const {getMock} = vi.hoisted(() => ({getMock: vi.fn()}));
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: getMock},
}));
const {fetchEmojiMetadata, fetchStickerMetadata} = await import(
'@app/features/expressions/commands/ExpressionMetadataCommands'
);
const BASE_BODY = {id: '10', guild_id: '20', name: 'blob', animated: false, allow_cloning: true};
describe('ExpressionMetadataCommands', () => {
afterEach(() => {
getMock.mockReset();
});
it.each([
['emoji', fetchEmojiMetadata],
['sticker', fetchStickerMetadata],
])('maps a %s response that has no guild to a null guild', async (_kind, fetchMetadata) => {
getMock.mockResolvedValue({body: BASE_BODY});
await expect(fetchMetadata('10')).resolves.toEqual({
id: '10',
guildId: '20',
name: 'blob',
animated: false,
allowCloning: true,
guild: null,
});
});
it('maps the source community when the response includes it', async () => {
getMock.mockResolvedValue({
body: {...BASE_BODY, guild: {id: '20', name: 'Blob Club', icon: null, features: ['VERIFIED']}},
});
const metadata = await fetchEmojiMetadata('10');
expect(metadata.guild).toEqual({id: '20', name: 'Blob Club', icon: null, features: ['VERIFIED']});
});
});
@@ -1,18 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Endpoints} from '@app/features/app/constants/Endpoints';
import type {ExpressionMetadata} from '@app/features/expressions/state/ExpressionMetadata';
import type {ExpressionMetadata, ExpressionMetadataGuild} from '@app/features/expressions/state/ExpressionMetadata';
import {http} from '@app/features/platform/transport/RestTransport';
import {Logger} from '@app/features/platform/utils/AppLogger';
const logger = new Logger('ExpressionMetadataCommands');
interface ExpressionMetadataGuildResponse {
id: string;
name: string;
icon?: string | null;
features: Array<string>;
}
interface ExpressionMetadataResponse {
id: string;
guild_id: string;
name: string;
animated: boolean;
allow_cloning: boolean;
guild?: ExpressionMetadataGuildResponse | null;
}
function mapGuild(guild: ExpressionMetadataGuildResponse): ExpressionMetadataGuild {
return {
id: guild.id,
name: guild.name,
icon: guild.icon ?? null,
features: guild.features,
};
}
function mapResponse(body: ExpressionMetadataResponse): ExpressionMetadata {
@@ -22,10 +39,11 @@ function mapResponse(body: ExpressionMetadataResponse): ExpressionMetadata {
name: body.name,
animated: body.animated,
allowCloning: body.allow_cloning,
guild: body.guild ? mapGuild(body.guild) : null,
};
}
type ExpressionKind = 'emoji' | 'sticker';
export type ExpressionKind = 'emoji' | 'sticker';
function metadataEndpoint(kind: ExpressionKind, id: string): string {
return kind === 'emoji' ? Endpoints.EMOJI_METADATA(id) : Endpoints.STICKER_METADATA(id);
@@ -0,0 +1,36 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.container {
display: flex;
align-items: center;
gap: 0.5rem;
text-align: left;
}
.preview {
display: block;
flex-shrink: 0;
width: 2rem;
height: 2rem;
min-width: 2rem;
min-height: 2rem;
object-fit: contain;
}
.text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.name {
font-weight: 600;
word-break: break-word;
}
.hint {
font-size: 0.75rem;
font-weight: 400;
color: var(--text-primary-muted);
}
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import styles from '@app/features/expressions/components/ExpressionHoverTooltipContent.module.css';
import {Trans} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
export interface ExpressionHoverTooltipContentProps {
displayName: string;
previewUrl?: string | null;
}
export const ExpressionHoverTooltipContent = observer(function ExpressionHoverTooltipContent({
displayName,
previewUrl,
}: ExpressionHoverTooltipContentProps) {
return (
<div className={styles.container} data-flx="expressions.expression-hover-tooltip-content.container">
{previewUrl && (
<img
src={previewUrl}
alt=""
draggable={false}
className={styles.preview}
data-flx="expressions.expression-hover-tooltip-content.preview"
/>
)}
<div className={styles.text} data-flx="expressions.expression-hover-tooltip-content.text">
<div className={styles.name} data-flx="expressions.expression-hover-tooltip-content.name">
{displayName}
</div>
<div className={styles.hint} data-flx="expressions.expression-hover-tooltip-content.hint">
<Trans comment="Muted second line of the emoji hover tooltip, hinting that clicking the emoji opens its info card.">
Click to learn more
</Trans>
</div>
</div>
</div>
);
});
ExpressionHoverTooltipContent.displayName = 'ExpressionHoverTooltipContent';
@@ -0,0 +1,211 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.card {
--expression-info-card-surface: var(--settings-surface-background, var(--form-surface-background));
--expression-info-card-border: var(--settings-border-color, var(--background-modifier-accent));
--expression-info-card-band: color-mix(in srgb, var(--text-primary) 4%, transparent);
--expression-info-card-row-hover: color-mix(in srgb, var(--text-primary) 8%, transparent);
--expression-info-card-shadow: 0 0.5rem 1rem rgb(0 0 0 / 0.22);
display: flex;
flex-direction: column;
width: 20rem;
max-width: 20rem;
overflow: hidden;
background: var(--expression-info-card-surface);
color: var(--text-primary);
border: 0.0625rem solid var(--expression-info-card-border);
border-radius: var(--radius-lg);
box-shadow: var(--expression-info-card-shadow);
}
:global(.theme-light) .card {
--expression-info-card-shadow: 0 0.5rem 1rem rgb(0 0 0 / 0.12);
}
.summarySection {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem;
user-select: text;
-webkit-user-select: text;
}
.preview {
display: block;
width: 3rem;
height: 3rem;
min-width: 3rem;
min-height: 3rem;
flex-shrink: 0;
object-fit: contain;
}
.summaryText {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.25rem;
}
.name {
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.25;
color: var(--text-primary);
overflow-wrap: anywhere;
}
.description {
font-size: 0.8125rem;
font-weight: 400;
line-height: 1.4;
color: var(--text-secondary);
}
.guildSection {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 0.875rem;
background: var(--expression-info-card-band);
border-top: 0.0625rem solid var(--expression-info-card-border);
user-select: none;
-webkit-user-select: none;
}
.guildLabel {
font-size: 0.6875rem;
font-weight: 600;
line-height: 1.25;
letter-spacing: 0.03125rem;
text-transform: uppercase;
color: var(--text-primary-muted);
}
.guildRow {
display: flex;
align-items: center;
gap: 0.625rem;
min-width: 0;
}
.guildRowButton {
width: calc(100% + 0.5rem);
padding: 0.25rem;
margin: -0.25rem;
background: none;
border: none;
border-radius: var(--radius-md);
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
.guildRowButton:hover {
background: var(--expression-info-card-row-hover);
}
.guildInfo {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.125rem;
}
.guildNameRow {
display: flex;
min-width: 0;
align-items: center;
gap: 0.25rem;
}
.guildName {
font-size: 0.875rem;
font-weight: 600;
line-height: 1.25;
color: var(--text-primary);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.guildSubtitle {
font-size: 0.75rem;
font-weight: 400;
line-height: 1.25;
color: var(--text-primary-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.guildSectionPlaceholder {
pointer-events: none;
}
.guildIconPlaceholder {
display: block;
width: 2.5rem;
height: 2.5rem;
min-width: 2.5rem;
min-height: 2.5rem;
flex-shrink: 0;
border-radius: var(--radius-full);
background: var(--expression-info-card-row-hover);
}
.joinConfirm {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
}
.joinConfirmHeading {
display: flex;
min-width: 0;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.joinConfirmNameRow {
display: flex;
min-width: 0;
align-items: center;
justify-content: center;
gap: 0.375rem;
}
.joinConfirmName {
font-size: 1.25rem;
font-weight: 700;
line-height: 1.25;
color: var(--text-primary);
overflow-wrap: anywhere;
}
.joinConfirmSubtitle {
font-size: 0.75rem;
font-weight: 400;
line-height: 1.25;
color: var(--text-primary-muted);
}
.joinConfirmText {
display: flex;
flex-direction: column;
gap: 0.375rem;
font-size: 0.875rem;
line-height: 1.4;
color: var(--text-primary-muted);
}
.joinConfirmQuestion {
color: var(--text-primary);
}
@@ -0,0 +1,382 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ExpressionKind} from '@app/features/expressions/commands/ExpressionMetadataCommands';
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import type React from 'react';
import {act} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {renderToStaticMarkup} from 'react-dom/server';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
interface RemoteGuild {
id: string;
name: string;
icon: string | null;
features: Array<string>;
}
interface LocalGuild {
id: string;
name: string;
icon: string | null;
features: Set<string>;
}
interface MetadataState {
loading: boolean;
error: Error | null;
data: {
id: string;
guildId: string;
name: string;
animated: boolean;
allowCloning: boolean;
guild: RemoteGuild | null;
} | null;
}
const state = vi.hoisted(() => ({
localGuilds: [] as Array<{id: string; name: string; icon: string | null; features: Set<string>}>,
memberGuildIds: [] as Array<string>,
globalExpressions: false,
emoji: {loading: false, error: null, data: null} as {
loading: boolean;
error: Error | null;
data: unknown;
},
sticker: {loading: false, error: null, data: null} as {
loading: boolean;
error: Error | null;
data: unknown;
},
selectGuild: vi.fn(),
joinDiscoveryGuild: vi.fn(() => Promise.resolve()),
pushWithKey: vi.fn(),
}));
vi.mock('@lingui/core/macro', () => {
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
});
vi.mock('@lingui/react/macro', () => ({
Trans: ({children}: {children?: React.ReactNode}) => <span data-flx="test.trans">{children}</span>,
useLingui: () => ({
i18n: {
locale: 'en',
_: (descriptor: {message?: string}, values?: Record<string, unknown>) =>
(descriptor.message ?? '').replace(/\{(\w+)\}/gu, (_match, key: string) => String(values?.[key] ?? `{${key}}`)),
},
}),
}));
vi.mock('@app/features/guild/state/Guilds', () => ({
default: {getGuild: (id: string) => state.localGuilds.find((guild) => guild.id === id)},
}));
vi.mock('@app/features/guild/state/GuildList', () => ({
default: {
get guilds() {
return state.memberGuildIds.map((id) => ({id}));
},
},
}));
vi.mock('@app/features/expressions/state/ExpressionMetadata', () => ({
default: {
getEmojiMetadata: () => state.emoji,
getStickerMetadata: () => state.sticker,
fetchEmojiMetadata: vi.fn(() => Promise.resolve()),
fetchStickerMetadata: vi.fn(() => Promise.resolve()),
},
}));
vi.mock('@app/features/expressions/utils/ExpressionPermissionUtils', () => ({
hasGlobalExpressionsEnabled: () => state.globalExpressions,
}));
vi.mock('@app/features/guild/components/popouts/GuildIcon', () => ({
GuildIcon: ({name}: {name: string}) => <span data-test-guild-icon={name} data-flx="test.guild-icon" />,
}));
vi.mock('@app/features/guild/components/GuildBadge', () => ({
GuildBadge: ({features}: {features: ReadonlyArray<string>}) => (
<span data-test-guild-badge={[...features].join(',')} data-flx="test.guild-badge" />
),
}));
vi.mock('@app/features/navigation/commands/NavigationCommands', () => ({selectGuild: state.selectGuild}));
vi.mock('@app/features/discovery/commands/DiscoveryJoinCommands', () => ({
joinDiscoveryGuild: state.joinDiscoveryGuild,
}));
vi.mock('@app/features/ui/commands/ModalCommands', () => ({
pushWithKey: state.pushWithKey,
modal: (render: () => React.ReactElement) => render,
}));
vi.mock('@app/features/app/components/dialogs/ConfirmModal', () => ({
ConfirmModal: ({
title,
description,
primaryText,
}: {
title: React.ReactNode;
description: React.ReactNode;
primaryText: React.ReactNode;
}) => (
<div data-test-confirm-modal="true" data-flx="test.confirm-modal">
<span data-test-confirm-title="true" data-flx="test.confirm-title">
{title}
</span>
{description}
<span data-test-confirm-primary="true" data-flx="test.confirm-primary">
{primaryText}
</span>
</div>
),
}));
installVoiceMenuTestBootstrap();
const {ExpressionInfoCard} = await import('@app/features/expressions/components/ExpressionInfoCard');
const GUILD_ID = '20';
const GUILD_NAME = 'Blob Club';
const EXPRESSION_ID = '10';
const DISPLAY_NAMES: Record<ExpressionKind, string> = {emoji: ':blob:', sticker: 'blobsticker'};
const IDLE_METADATA: MetadataState = {loading: false, error: null, data: null};
function localGuild(features: Array<string>): LocalGuild {
return {id: GUILD_ID, name: GUILD_NAME, icon: null, features: new Set(features)};
}
function remoteMetadata(features: Array<string>): MetadataState {
return {
loading: false,
error: null,
data: {
id: EXPRESSION_ID,
guildId: GUILD_ID,
name: 'blob',
animated: false,
allowCloning: false,
guild: {id: GUILD_ID, name: GUILD_NAME, icon: null, features},
},
};
}
function setMetadata(metadata: MetadataState): void {
state.emoji = metadata;
state.sticker = metadata;
}
function joinedCommunity(features: Array<string>): void {
state.localGuilds = [localGuild(features)];
state.memberGuildIds = [GUILD_ID];
}
function foreignCommunity(features: Array<string>): void {
state.localGuilds = [];
state.memberGuildIds = [];
setMetadata(remoteMetadata(features));
}
function renderCard(kind: ExpressionKind, guildId: string | null): string {
return renderToStaticMarkup(
<ExpressionInfoCard
kind={kind}
expressionId={EXPRESSION_ID}
guildId={guildId}
displayName={DISPLAY_NAMES[kind]}
previewUrl={null}
data-flx="test.expression-info-card"
/>,
);
}
beforeEach(() => {
state.localGuilds = [];
state.memberGuildIds = [];
state.globalExpressions = false;
setMetadata(IDLE_METADATA);
state.selectGuild.mockClear();
state.joinDiscoveryGuild.mockClear();
state.pushWithKey.mockClear();
});
describe('ExpressionInfoCard description', () => {
it('describes a default emoji as usable anywhere', () => {
const markup = renderToStaticMarkup(
<ExpressionInfoCard
kind="default_emoji"
displayName=":smile:"
previewUrl={null}
data-flx="test.expression-info-card.default"
/>,
);
expect(markup).toContain('A default emoji. You can use it anywhere on Fluxer.');
});
for (const kind of ['emoji', 'sticker'] as const) {
it(`describes a ${kind} from a joined community as usable anywhere when global expressions are on`, () => {
joinedCommunity([]);
state.globalExpressions = true;
expect(renderCard(kind, GUILD_ID)).toContain(
`A custom ${kind} from this community. You can use it anywhere on Fluxer.`,
);
});
it(`describes a ${kind} from a joined community as community-only when global expressions are off`, () => {
joinedCommunity([]);
expect(renderCard(kind, GUILD_ID)).toContain(
`A custom ${kind} from this community. You can use it in this community.`,
);
});
it(`describes a ${kind} from a discoverable community the viewer has not joined`, () => {
foreignCommunity([GuildFeatures.DISCOVERABLE]);
const markup = renderCard(kind, null);
expect(markup).toContain(`This is a custom ${kind} from a community.`);
expect(markup).not.toContain('Ask the author');
});
it(`describes a ${kind} from an invite-only community the viewer has not joined without repeating the footer`, () => {
foreignCommunity([]);
const markup = renderCard(kind, null);
expect(markup).toContain(`This is a custom ${kind} from a community.`);
expect(markup).not.toContain('Ask the author');
expect(markup).toContain('Invite-only community');
});
it(`falls back to the generic description when the ${kind} source community never resolves`, () => {
setMetadata({loading: false, error: new Error('lookup failed'), data: null});
const markup = renderCard(kind, null);
expect(markup).toContain(`This is a custom ${kind} from a community.`);
expect(markup).not.toContain('This emoji is from');
expect(markup).not.toContain('This sticker is from');
});
it(`labels the source community section for a ${kind}`, () => {
foreignCommunity([GuildFeatures.DISCOVERABLE]);
const markup = renderCard(kind, null);
expect(markup).toContain(kind === 'emoji' ? 'This emoji is from' : 'This sticker is from');
expect(markup).not.toContain(kind === 'emoji' ? 'This sticker is from' : 'This emoji is from');
});
it(`keeps the ${kind} description identical while the source community resolves`, () => {
const expected = `This is a custom ${kind} from a community.`;
setMetadata({loading: true, error: null, data: null});
const resolving = renderCard(kind, null);
expect(resolving).toContain(DISPLAY_NAMES[kind]);
expect(resolving).toContain(expected);
foreignCommunity([]);
expect(renderCard(kind, null)).toContain(expected);
});
}
it('does not colon-wrap a sticker display name', () => {
joinedCommunity([]);
const markup = renderCard('sticker', GUILD_ID);
expect(markup).toContain('>blobsticker<');
expect(markup).not.toContain(':blobsticker:');
});
});
describe('ExpressionInfoCard community subtitle', () => {
for (const isMember of [true, false]) {
const membership = isMember ? 'a joined' : 'a foreign';
it(`calls ${membership} discoverable community discoverable`, () => {
if (isMember) {
joinedCommunity([GuildFeatures.DISCOVERABLE]);
} else {
foreignCommunity([GuildFeatures.DISCOVERABLE]);
}
const markup = renderCard('emoji', isMember ? GUILD_ID : null);
expect(markup).toContain('Discoverable community');
expect(markup).not.toContain('Invite-only community');
});
it(`calls ${membership} non-discoverable community invite-only`, () => {
if (isMember) {
joinedCommunity([]);
} else {
foreignCommunity([]);
}
const markup = renderCard('emoji', isMember ? GUILD_ID : null);
expect(markup).toContain('Invite-only community');
expect(markup).not.toContain('Discoverable community');
});
}
});
describe('ExpressionInfoCard community row', () => {
let host: HTMLDivElement;
let root: Root;
function render(guildId: string | null, onClose: () => void): void {
act(() => {
root.render(
<ExpressionInfoCard
kind="emoji"
expressionId={EXPRESSION_ID}
guildId={guildId}
displayName=":blob:"
previewUrl={null}
onClose={onClose}
data-flx="test.expression-info-card.row"
/>,
);
});
}
beforeEach(() => {
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
afterEach(() => {
act(() => {
root.unmount();
});
host.remove();
});
it('jumps to a community the viewer is a member of', () => {
joinedCommunity([GuildFeatures.DISCOVERABLE]);
const onClose = vi.fn();
render(GUILD_ID, onClose);
const button = host.querySelector('button');
expect(button?.getAttribute('aria-label')).toBe(`Go to ${GUILD_NAME}`);
act(() => {
button?.dispatchEvent(new MouseEvent('click', {bubbles: true}));
});
expect(state.selectGuild).toHaveBeenCalledWith(GUILD_ID);
expect(state.pushWithKey).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
it('confirms before joining a discoverable community the viewer is not in', async () => {
foreignCommunity([GuildFeatures.DISCOVERABLE]);
const onClose = vi.fn();
render(null, onClose);
const button = host.querySelector('button');
expect(button?.getAttribute('aria-label')).toBe(`Join ${GUILD_NAME}`);
act(() => {
button?.dispatchEvent(new MouseEvent('click', {bubbles: true}));
});
expect(state.selectGuild).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
expect(state.pushWithKey).toHaveBeenCalledTimes(1);
expect(state.joinDiscoveryGuild).not.toHaveBeenCalled();
const [renderModal, modalKey] = state.pushWithKey.mock.calls[0] as [() => React.ReactElement, string];
expect(modalKey).toBe(`expression-source-guild-join-${GUILD_ID}`);
const modal = renderModal();
expect(renderToStaticMarkup(modal)).toContain(`Do you want to join ${GUILD_NAME}?`);
const {onPrimary} = modal.props as {onPrimary: () => Promise<void>};
await onPrimary();
expect(state.joinDiscoveryGuild).toHaveBeenCalledWith(GUILD_ID);
});
it('leaves an invite-only community the viewer is not in unclickable', () => {
foreignCommunity([]);
render(null, vi.fn());
expect(host.querySelector('button')).toBeNull();
expect(host.querySelector('[tabindex]')).toBeNull();
expect(host.innerHTML).toContain(GUILD_NAME);
});
});
@@ -0,0 +1,444 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {joinDiscoveryGuild} from '@app/features/discovery/commands/DiscoveryJoinCommands';
import type {ExpressionKind} from '@app/features/expressions/commands/ExpressionMetadataCommands';
import styles from '@app/features/expressions/components/ExpressionInfoCard.module.css';
import ExpressionMetadata from '@app/features/expressions/state/ExpressionMetadata';
import {hasGlobalExpressionsEnabled} from '@app/features/expressions/utils/ExpressionPermissionUtils';
import {GuildBadge} from '@app/features/guild/components/GuildBadge';
import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon';
import type {Guild} from '@app/features/guild/models/Guild';
import GuildList from '@app/features/guild/state/GuildList';
import Guilds from '@app/features/guild/state/Guilds';
import {
DISCOVERABLE_COMMUNITY_DESCRIPTOR,
JOIN_COMMUNITY_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import type {MessageDescriptor} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect} from 'react';
const DEFAULT_EMOJI_DESCRIPTION_DESCRIPTOR = msg({
message: 'A default emoji. You can use it anywhere on {productName}.',
comment: 'Description of a built-in unicode emoji in the emoji info card. productName is the Fluxer product name.',
});
const GUILD_EXPRESSION_ANYWHERE_DESCRIPTORS: Record<ExpressionKind, MessageDescriptor> = {
emoji: msg({
message: 'A custom emoji from this community. You can use it anywhere on {productName}.',
comment:
'Description of a custom emoji in the emoji info card when the viewer can use it outside the community. productName is the Fluxer product name.',
}),
sticker: msg({
message: 'A custom sticker from this community. You can use it anywhere on {productName}.',
comment:
'Description of a custom sticker in the sticker info card when the viewer can use it outside the community. productName is the Fluxer product name.',
}),
};
const GUILD_EXPRESSION_HERE_DESCRIPTORS: Record<ExpressionKind, MessageDescriptor> = {
emoji: msg({
message: 'A custom emoji from this community. You can use it in this community.',
comment:
'Description of a custom emoji in the emoji info card when the viewer can only use it inside the community.',
}),
sticker: msg({
message: 'A custom sticker from this community. You can use it in this community.',
comment:
'Description of a custom sticker in the sticker info card when the viewer can only use it inside the community.',
}),
};
const FOREIGN_EXPRESSION_DESCRIPTORS: Record<ExpressionKind, MessageDescriptor> = {
emoji: msg({
message: 'This is a custom emoji from a community.',
comment: 'Description of a custom emoji from a community the viewer is not a member of.',
}),
sticker: msg({
message: 'This is a custom sticker from a community.',
comment: 'Description of a custom sticker from a community the viewer is not a member of.',
}),
};
const SOURCE_COMMUNITY_LABEL_DESCRIPTORS: Record<ExpressionKind, MessageDescriptor> = {
emoji: msg({
message: 'This emoji is from',
comment: 'Label above the community an emoji comes from in the emoji info card.',
}),
sticker: msg({
message: 'This sticker is from',
comment: 'Label above the community a sticker comes from in the sticker info card.',
}),
};
const INVITE_ONLY_COMMUNITY_DESCRIPTOR = msg({
message: 'Invite-only community',
comment: 'Subtitle for a community that can only be joined with an invite.',
});
const GO_TO_NAMED_COMMUNITY_DESCRIPTOR = msg({
message: 'Go to {communityName}',
comment:
'Accessible label for the community row of an expression info card, which opens a community the viewer is already a member of. {communityName} is the community name.',
});
const JOIN_NAMED_COMMUNITY_DESCRIPTOR = msg({
message: 'Join {communityName}',
comment: 'Accessible label of a button that joins a community. {communityName} is the community name.',
});
const JOIN_COMMUNITY_REASON_DESCRIPTORS: Record<ExpressionKind, MessageDescriptor> = {
emoji: msg({
message: '{expressionName} is a custom emoji from this community. Members can use it here.',
comment:
'Reason shown in the confirmation before joining a community from an emoji info card. It explains that the emoji the viewer opened belongs to this community. {expressionName} is the emoji name, written with colons around it.',
}),
sticker: msg({
message: '{expressionName} is a custom sticker from this community. Members can use it here.',
comment:
'Reason shown in the confirmation before joining a community from a sticker info card. It explains that the sticker the viewer opened belongs to this community. {expressionName} is the sticker name.',
}),
};
const JOIN_COMMUNITY_CONFIRMATION_DESCRIPTOR = msg({
message: 'Do you want to join {communityName}?',
comment:
'Body of the confirmation shown before joining a discoverable community from an expression info card. {communityName} is the community name.',
});
const GUILD_ICON_SIZE_PX = 40;
const JOIN_CONFIRM_GUILD_ICON_SIZE_PX = 80;
interface ExpressionSourceGuild {
id: string;
name: string;
icon: string | null;
features: ReadonlyArray<string>;
}
interface ExpressionInfoCardCommonProps {
displayName: string;
previewUrl: string | null;
className?: string;
onClose?: () => void;
}
export type ExpressionInfoCardProps = ExpressionInfoCardCommonProps &
({kind: 'default_emoji'} | {kind: ExpressionKind; expressionId: string; guildId: string | null});
function toSourceGuild(guild: Guild): ExpressionSourceGuild {
return {id: guild.id, name: guild.name, icon: guild.icon, features: [...guild.features]};
}
function readMetadataState(kind: ExpressionKind, expressionId: string) {
return kind === 'emoji'
? ExpressionMetadata.getEmojiMetadata(expressionId)
: ExpressionMetadata.getStickerMetadata(expressionId);
}
interface ExpressionSourceGuildState {
guild: ExpressionSourceGuild | null;
isResolving: boolean;
isRemoteSource: boolean;
}
function useExpressionSourceGuild(
kind: ExpressionKind | null,
expressionId: string | null,
guildId: string | null,
): ExpressionSourceGuildState {
const isLocallyKnown = guildId != null && Guilds.getGuild(guildId) != null;
const metadataState =
kind != null && expressionId != null && !isLocallyKnown ? readMetadataState(kind, expressionId) : null;
const isRemoteSource = metadataState != null;
const isResolving = metadataState != null && metadataState.data == null && metadataState.error == null;
const shouldFetch = isResolving && metadataState?.loading !== true;
useEffect(() => {
if (!shouldFetch || kind == null || expressionId == null) {
return;
}
const request =
kind === 'emoji'
? ExpressionMetadata.fetchEmojiMetadata(expressionId)
: ExpressionMetadata.fetchStickerMetadata(expressionId);
void request.catch(() => undefined);
}, [shouldFetch, kind, expressionId]);
const metadata = metadataState?.data ?? null;
const resolvedGuildId = guildId ?? metadata?.guildId ?? null;
const localGuild = resolvedGuildId != null ? Guilds.getGuild(resolvedGuildId) : null;
if (localGuild != null) {
return {guild: toSourceGuild(localGuild), isResolving: false, isRemoteSource};
}
if (metadata?.guild != null) {
return {
guild: {
id: metadata.guild.id,
name: metadata.guild.name,
icon: metadata.guild.icon,
features: metadata.guild.features,
},
isResolving: false,
isRemoteSource,
};
}
return {guild: null, isResolving, isRemoteSource};
}
interface ExpressionJoinConfirmationProps {
kind: ExpressionKind;
guild: ExpressionSourceGuild;
expressionName: string;
}
const ExpressionJoinConfirmation = observer(function ExpressionJoinConfirmation({
kind,
guild,
expressionName,
}: ExpressionJoinConfirmationProps) {
const {i18n} = useLingui();
return (
<div className={styles.joinConfirm} data-flx="expressions.expression-info-card.join-confirmation.join-confirm">
<GuildIcon
id={guild.id}
name={guild.name}
icon={guild.icon}
sizePx={JOIN_CONFIRM_GUILD_ICON_SIZE_PX}
data-flx="expressions.expression-info-card.join-confirmation.guild-icon"
/>
<div
className={styles.joinConfirmHeading}
data-flx="expressions.expression-info-card.join-confirmation.join-confirm-heading"
>
<span
className={styles.joinConfirmNameRow}
data-flx="expressions.expression-info-card.join-confirmation.join-confirm-name-row"
>
<span
className={styles.joinConfirmName}
data-flx="expressions.expression-info-card.join-confirmation.join-confirm-name"
>
{guild.name}
</span>
<GuildBadge
features={guild.features}
data-flx="expressions.expression-info-card.join-confirmation.guild-badge"
/>
</span>
<span
className={styles.joinConfirmSubtitle}
data-flx="expressions.expression-info-card.join-confirmation.join-confirm-subtitle"
>
{i18n._(DISCOVERABLE_COMMUNITY_DESCRIPTOR)}
</span>
</div>
<div
className={styles.joinConfirmText}
data-flx="expressions.expression-info-card.join-confirmation.join-confirm-text"
>
<span data-flx="expressions.expression-info-card.join-confirmation.join-confirm-reason">
{i18n._(JOIN_COMMUNITY_REASON_DESCRIPTORS[kind], {expressionName})}
</span>
<span
className={styles.joinConfirmQuestion}
data-flx="expressions.expression-info-card.join-confirmation.join-confirm-question"
>
{i18n._(JOIN_COMMUNITY_CONFIRMATION_DESCRIPTOR, {communityName: guild.name})}
</span>
</div>
</div>
);
});
interface ExpressionSourceGuildRowProps {
kind: ExpressionKind;
guild: ExpressionSourceGuild;
expressionName: string;
isMember: boolean;
onClose?: () => void;
}
const ExpressionSourceGuildRow = observer(function ExpressionSourceGuildRow({
kind,
guild,
expressionName,
isMember,
onClose,
}: ExpressionSourceGuildRowProps) {
const {i18n} = useLingui();
const isDiscoverable = guild.features.includes(GuildFeatures.DISCOVERABLE);
const handleJump = useCallback(() => {
NavigationCommands.selectGuild(guild.id);
onClose?.();
}, [guild.id, onClose]);
const handleJoin = useCallback(() => {
onClose?.();
const modalKey = `expression-source-guild-join-${guild.id}`;
ModalCommands.pushWithKey(
modal(() => (
<ConfirmModal
title={i18n._(JOIN_COMMUNITY_DESCRIPTOR)}
description={
<ExpressionJoinConfirmation
kind={kind}
guild={guild}
expressionName={expressionName}
data-flx="expressions.expression-info-card.handle-join.expression-join-confirmation"
/>
}
primaryText={i18n._(JOIN_COMMUNITY_DESCRIPTOR)}
disableAutoDismiss
onPrimary={async () => {
if (await joinDiscoveryGuild(guild.id)) {
ModalCommands.popWithKey(modalKey);
}
}}
data-flx="expressions.expression-info-card.handle-join.confirm-modal"
/>
)),
modalKey,
);
}, [guild, kind, expressionName, i18n, onClose]);
const canJoin = isDiscoverable && !RuntimeConfig.singleCommunityEnabled;
const activate = isMember ? handleJump : canJoin ? handleJoin : null;
const accessibleName = isMember
? i18n._(GO_TO_NAMED_COMMUNITY_DESCRIPTOR, {communityName: guild.name})
: i18n._(JOIN_NAMED_COMMUNITY_DESCRIPTOR, {communityName: guild.name});
const rowBody = (
<>
<GuildIcon
id={guild.id}
name={guild.name}
icon={guild.icon}
sizePx={GUILD_ICON_SIZE_PX}
data-flx="expressions.expression-info-card.source-guild-row.guild-icon"
/>
<span className={styles.guildInfo} data-flx="expressions.expression-info-card.source-guild-row.guild-info">
<span
className={styles.guildNameRow}
data-flx="expressions.expression-info-card.source-guild-row.guild-name-row"
>
<span className={styles.guildName} data-flx="expressions.expression-info-card.source-guild-row.guild-name">
{guild.name}
</span>
<GuildBadge
features={guild.features}
onLightSurface
data-flx="expressions.expression-info-card.source-guild-row.guild-badge"
/>
</span>
<span
className={styles.guildSubtitle}
data-flx="expressions.expression-info-card.source-guild-row.guild-subtitle"
>
{isDiscoverable ? i18n._(DISCOVERABLE_COMMUNITY_DESCRIPTOR) : i18n._(INVITE_ONLY_COMMUNITY_DESCRIPTOR)}
</span>
</span>
</>
);
return (
<div className={styles.guildSection} data-flx="expressions.expression-info-card.source-guild-row.guild-section">
<span className={styles.guildLabel} data-flx="expressions.expression-info-card.source-guild-row.guild-label">
{i18n._(SOURCE_COMMUNITY_LABEL_DESCRIPTORS[kind])}
</span>
{activate == null ? (
<div className={styles.guildRow} data-flx="expressions.expression-info-card.source-guild-row.guild-row">
{rowBody}
</div>
) : (
<FocusRing offset={-2} data-flx="expressions.expression-info-card.source-guild-row.focus-ring">
<button
type="button"
className={clsx(styles.guildRow, styles.guildRowButton)}
aria-label={accessibleName}
onClick={activate}
data-flx="expressions.expression-info-card.source-guild-row.guild-row.activate"
>
{rowBody}
</button>
</FocusRing>
)}
</div>
);
});
const ExpressionSourceGuildPlaceholder = () => (
<div
className={clsx(styles.guildSection, styles.guildSectionPlaceholder)}
aria-hidden
data-flx="expressions.expression-info-card.source-guild-placeholder.guild-section"
>
<span
className={styles.guildLabel}
data-flx="expressions.expression-info-card.source-guild-placeholder.guild-label"
>
{'\u00a0'}
</span>
<span className={styles.guildRow} data-flx="expressions.expression-info-card.source-guild-placeholder.guild-row">
<span
className={styles.guildIconPlaceholder}
data-flx="expressions.expression-info-card.source-guild-placeholder.guild-icon-placeholder"
/>
</span>
</div>
);
export const ExpressionInfoCard = observer(function ExpressionInfoCard(props: ExpressionInfoCardProps) {
const {displayName, previewUrl, className, onClose} = props;
const {i18n} = useLingui();
const kind: ExpressionKind | null = props.kind === 'default_emoji' ? null : props.kind;
const expressionId = props.kind === 'default_emoji' ? null : props.expressionId;
const guildId = props.kind === 'default_emoji' ? null : props.guildId;
const {guild: sourceGuild, isRemoteSource} = useExpressionSourceGuild(kind, expressionId, guildId);
const isMember = sourceGuild != null && GuildList.guilds.some((candidate) => candidate.id === sourceGuild.id);
const resolveDescription = (): string => {
if (kind == null) {
return i18n._(DEFAULT_EMOJI_DESCRIPTION_DESCRIPTOR, {productName: PRODUCT_NAME});
}
if (sourceGuild != null && isMember) {
return hasGlobalExpressionsEnabled()
? i18n._(GUILD_EXPRESSION_ANYWHERE_DESCRIPTORS[kind], {productName: PRODUCT_NAME})
: i18n._(GUILD_EXPRESSION_HERE_DESCRIPTORS[kind]);
}
return i18n._(FOREIGN_EXPRESSION_DESCRIPTORS[kind]);
};
return (
<div className={clsx(styles.card, className)} data-flx="expressions.expression-info-card.card">
<div className={styles.summarySection} data-flx="expressions.expression-info-card.summary-section">
{previewUrl && (
<img
src={previewUrl}
alt=""
draggable={false}
className={styles.preview}
data-flx="expressions.expression-info-card.preview"
/>
)}
<div className={styles.summaryText} data-flx="expressions.expression-info-card.summary-text">
<span className={styles.name} data-flx="expressions.expression-info-card.name">
{displayName}
</span>
<span className={styles.description} data-flx="expressions.expression-info-card.description">
{resolveDescription()}
</span>
</div>
</div>
{kind != null && sourceGuild != null && (
<ExpressionSourceGuildRow
kind={kind}
guild={sourceGuild}
expressionName={displayName}
isMember={isMember}
onClose={onClose}
data-flx="expressions.expression-info-card.expression-source-guild-row"
/>
)}
{isRemoteSource && sourceGuild == null && (
<ExpressionSourceGuildPlaceholder data-flx="expressions.expression-info-card.expression-source-guild-placeholder" />
)}
</div>
);
});
ExpressionInfoCard.displayName = 'ExpressionInfoCard';
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {Popout} from '@app/features/ui/popover/PopoverPopout';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback} from 'react';
const CLICKABLE_ANCESTOR_SELECTOR = 'a, button, [role="button"], [role="link"]';
interface ExpressionInfoPopoutProps {
children: React.ReactElement;
canOpenCard?: boolean;
renderTooltip: () => React.ReactNode;
renderCard: (props: {onClose: () => void}) => React.ReactNode;
}
export const ExpressionInfoPopout = observer(function ExpressionInfoPopout({
children,
canOpenCard = true,
renderTooltip,
renderCard,
}: ExpressionInfoPopoutProps): React.ReactElement {
const shouldOpenOnClick = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (!canOpenCard) {
return false;
}
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
return false;
}
return event.currentTarget.parentElement?.closest(CLICKABLE_ANCESTOR_SELECTOR) == null;
},
[canOpenCard],
);
return (
<Popout
position="top"
animationType="smooth"
offsetMainAxis={8}
tooltip={renderTooltip}
tooltipPosition="top"
render={renderCard}
shouldOpenOnClick={shouldOpenOnClick}
data-flx="expressions.expression-info-popout.popout"
>
<FocusRing offset={-2} data-flx="expressions.expression-info-popout.focus-ring">
{children}
</FocusRing>
</Popout>
);
});
@@ -0,0 +1,13 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.content {
display: flex;
flex-direction: column;
padding: 1rem 1rem 2rem;
}
.content .card {
width: 100%;
max-width: none;
box-shadow: none;
}
@@ -0,0 +1,172 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
import {useStickerAnimation} from '@app/features/emoji/hooks/useStickerAnimation';
import Emoji from '@app/features/emoji/state/Emoji';
import Sticker from '@app/features/emoji/state/EmojiSticker';
import styles from '@app/features/expressions/components/bottomsheets/ExpressionInfoBottomSheet.module.css';
import {ExpressionInfoCard} from '@app/features/expressions/components/ExpressionInfoCard';
import {
buildCustomEmojiURL,
CUSTOM_EMOJI_ENLARGED_IMAGE_RUNG,
} from '@app/features/expressions/utils/CustomEmojiImageUrl';
import * as EmojiUtils from '@app/features/expressions/utils/EmojiUtils';
import {
EXPRESSION_INFO_SURFACE_OPEN_IS_INTERACTION,
STICKER_PREVIEW_SIZE,
} from '@app/features/expressions/utils/ExpressionPreviewConstants';
import UnicodeEmojis from '@app/features/expressions/utils/UnicodeEmojis';
import {BottomSheet} from '@app/features/ui/bottom_sheet/BottomSheet';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useMemo} from 'react';
interface EmojiInfoData {
id?: string;
name: string;
animated?: boolean;
}
interface StickerInfoData {
id: string;
name: string;
animated: boolean;
}
type ExpressionInfoBottomSheetProps = {
isOpen: boolean;
onClose: () => void;
} & ({kind: 'emoji'; emoji: EmojiInfoData | null} | {kind: 'sticker'; sticker: StickerInfoData | null});
const EXPRESSION_SHEET_SNAP_POINTS: Array<number> = [0, 0.4, 0.5];
export const ExpressionInfoBottomSheet: React.FC<ExpressionInfoBottomSheetProps> = observer((props) => {
if (!props.isOpen) {
return null;
}
if (props.kind === 'sticker') {
return props.sticker ? (
<ExpressionInfoBottomSheetShell
onClose={props.onClose}
data-flx="expressions.expression-info-bottom-sheet.shell.sticker"
>
<StickerInfoCard
sticker={props.sticker}
onClose={props.onClose}
data-flx="expressions.expression-info-bottom-sheet.sticker-info-card"
/>
</ExpressionInfoBottomSheetShell>
) : null;
}
return props.emoji ? (
<ExpressionInfoBottomSheetShell
onClose={props.onClose}
data-flx="expressions.expression-info-bottom-sheet.shell.emoji"
>
<EmojiInfoCard
emoji={props.emoji}
onClose={props.onClose}
data-flx="expressions.expression-info-bottom-sheet.emoji-info-card"
/>
</ExpressionInfoBottomSheetShell>
) : null;
});
interface ExpressionInfoBottomSheetShellProps {
onClose: () => void;
children: React.ReactNode;
}
const ExpressionInfoBottomSheetShell = ({onClose, children}: ExpressionInfoBottomSheetShellProps) => (
<BottomSheet
isOpen={true}
onClose={onClose}
snapPoints={EXPRESSION_SHEET_SNAP_POINTS}
initialSnap={EXPRESSION_SHEET_SNAP_POINTS.length - 1}
showCloseButton={false}
data-flx="expressions.expression-info-bottom-sheet.shell.bottom-sheet"
>
<div className={styles.content} data-flx="expressions.expression-info-bottom-sheet.shell.content">
{children}
</div>
</BottomSheet>
);
interface EmojiInfoCardProps {
emoji: EmojiInfoData;
onClose: () => void;
}
const EmojiInfoCard = observer(function EmojiInfoCard({emoji, onClose}: EmojiInfoCardProps) {
const isCustomEmoji = emoji.id != null;
const shouldAnimateEmoji = useShouldAnimate({kind: 'emoji', isAnimated: Boolean(emoji.animated)});
const emojiRecord = isCustomEmoji ? Emoji.getEmojiById(emoji.id!) : null;
const guildId = emojiRecord?.guildId ?? null;
const defaultEmojiSurrogate = isCustomEmoji ? null : UnicodeEmojis.normalizeEmojiNameToSurrogate(emoji.name);
const emojiUrl = useMemo(() => {
if (isCustomEmoji) {
return buildCustomEmojiURL({
id: emoji.id!,
animated: Boolean(emoji.animated) && shouldAnimateEmoji,
size: CUSTOM_EMOJI_ENLARGED_IMAGE_RUNG,
});
}
return EmojiUtils.getEmojiURL(defaultEmojiSurrogate ?? emoji.name);
}, [emoji.id, emoji.name, emoji.animated, isCustomEmoji, defaultEmojiSurrogate, shouldAnimateEmoji]);
const displayName = isCustomEmoji
? `:${emoji.name}:`
: UnicodeEmojis.nameForSurrogate(defaultEmojiSurrogate ?? emoji.name, true, `:${emoji.name}:`);
return emoji.id != null ? (
<ExpressionInfoCard
kind="emoji"
expressionId={emoji.id}
guildId={guildId}
displayName={displayName}
previewUrl={emojiUrl}
onClose={onClose}
className={styles.card}
data-flx="expressions.expression-info-bottom-sheet.emoji-info-card.expression-info-card.custom"
/>
) : (
<ExpressionInfoCard
kind="default_emoji"
displayName={displayName}
previewUrl={emojiUrl}
onClose={onClose}
className={styles.card}
data-flx="expressions.expression-info-bottom-sheet.emoji-info-card.expression-info-card.default"
/>
);
});
interface StickerInfoCardProps {
sticker: StickerInfoData;
onClose: () => void;
}
const StickerInfoCard = observer(function StickerInfoCard({sticker, onClose}: StickerInfoCardProps) {
const {shouldAnimate: shouldAnimateSticker} = useStickerAnimation({
isAnimated: sticker.animated,
isInteracting: EXPRESSION_INFO_SURFACE_OPEN_IS_INTERACTION,
});
const guildId = Sticker.getStickerById(sticker.id)?.guildId ?? null;
const previewUrl = AvatarUtils.getStickerURL({
id: sticker.id,
animated: shouldAnimateSticker,
isAnimatable: sticker.animated,
size: STICKER_PREVIEW_SIZE,
});
return (
<ExpressionInfoCard
kind="sticker"
expressionId={sticker.id}
guildId={guildId}
displayName={sticker.name}
previewUrl={previewUrl}
onClose={onClose}
className={styles.card}
data-flx="expressions.expression-info-bottom-sheet.sticker-info-card.expression-info-card"
/>
);
});
@@ -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 {
type ExpressionInfoCardAssignmentResponse,
INERT_EXPRESSION_INFO_CARD_ASSIGNMENT,
} from '@fluxer/schema/src/domains/experiment/ExpressionInfoCardSchemas';
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 {ExpressionInfoCardRollout} = await import('@app/features/expressions/state/ExpressionInfoCardRollout');
function publish(assignment: ExpressionInfoCardAssignmentResponse): void {
const response: ExperimentAssignmentsResponse = {
poll_interval_seconds: 300,
poll_jitter_percent: 15,
assignments: {expression_info_card: assignment},
};
runInAction(() => {
ExperimentAssignments.response = response;
});
}
afterEach(() => {
runInAction(() => {
ExperimentAssignments.response = INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE;
});
});
describe('ExpressionInfoCardRollout', () => {
it('reads the inert assignment out of the inert envelope', () => {
expect(ExpressionInfoCardRollout.assignment).toBe(INERT_EXPRESSION_INFO_CARD_ASSIGNMENT);
expect(ExpressionInfoCardRollout.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(ExpressionInfoCardRollout.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(ExpressionInfoCardRollout.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(ExpressionInfoCardRollout.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(ExpressionInfoCardRollout.enabled).toBe(true);
ExperimentAssignments.reset();
expect(ExpressionInfoCardRollout.enabled).toBe(false);
});
});
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import {readExpressionInfoCardAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import type {ExpressionInfoCardAssignmentResponse} from '@fluxer/schema/src/domains/experiment/ExpressionInfoCardSchemas';
class ExpressionInfoCardRolloutSelector {
get assignment(): ExpressionInfoCardAssignmentResponse {
return readExpressionInfoCardAssignment(ExperimentAssignments.response);
}
get enabled(): boolean {
const assignment = this.assignment;
return assignment.enabled && assignment.user_targeted;
}
}
export const ExpressionInfoCardRollout = new ExpressionInfoCardRolloutSelector();
export default ExpressionInfoCardRollout;

Some files were not shown because too many files have changed in this diff Show More