mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 11:42:32 +09:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6339c3b8ad | ||
|
|
7c9274847f | ||
|
|
5d1dddc093 | ||
|
|
04481d7235 | ||
|
|
a3d6cf37cb | ||
|
|
ed10f9d323 | ||
|
|
4b278a0da8 | ||
|
|
1281045648 | ||
|
|
69c42cff90 | ||
|
|
7d56481aba |
Generated
+14
-13
@@ -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]]
|
||||
|
||||
@@ -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, ®istry);
|
||||
}
|
||||
for response in components.responses.values_mut() {
|
||||
if let openapiv3::ReferenceOr::Item(response) = response {
|
||||
relax_response(response, ®istry);
|
||||
}
|
||||
}
|
||||
for parameter in components.parameters.values_mut() {
|
||||
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
|
||||
relax_parameter(parameter, ®istry);
|
||||
}
|
||||
}
|
||||
for request_body in components.request_bodies.values_mut() {
|
||||
if let openapiv3::ReferenceOr::Item(request_body) = request_body {
|
||||
relax_content(&mut request_body.content, ®istry);
|
||||
}
|
||||
}
|
||||
for header in components.headers.values_mut() {
|
||||
if let openapiv3::ReferenceOr::Item(header) = header {
|
||||
relax_parameter_format(&mut header.format, ®istry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, ®istry);
|
||||
}
|
||||
}
|
||||
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, ®istry);
|
||||
}
|
||||
}
|
||||
if let Some(openapiv3::ReferenceOr::Item(request_body)) =
|
||||
operation.request_body.as_mut()
|
||||
{
|
||||
relax_content(&mut request_body.content, ®istry);
|
||||
}
|
||||
for response in operation
|
||||
.responses
|
||||
.responses
|
||||
.values_mut()
|
||||
.chain(operation.responses.default.iter_mut())
|
||||
{
|
||||
if let openapiv3::ReferenceOr::Item(response) = response {
|
||||
relax_response(response, ®istry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+138
@@ -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();
|
||||
});
|
||||
});
|
||||
+103
-64
@@ -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}
|
||||
|
||||
+1
@@ -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"
|
||||
/>
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle';
|
||||
import {act, useRef} from 'react';
|
||||
import {createRoot, type Root} from 'react-dom/client';
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const {canFocusTextareaMock} = vi.hoisted(() => ({canFocusTextareaMock: vi.fn(() => true)}));
|
||||
|
||||
vi.mock('@app/features/platform/utils/InputFocusManager', () => ({
|
||||
canFocusTextarea: canFocusTextareaMock,
|
||||
}));
|
||||
|
||||
const {useChannelComposerDraftFocusRestore} = await import(
|
||||
'@app/features/channel/components/useChannelComposerDraftFocusRestore'
|
||||
);
|
||||
|
||||
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let focusSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
interface HarnessProps {
|
||||
initialDraft: string;
|
||||
textareaInputDisabled?: boolean;
|
||||
inlineEditActive?: boolean;
|
||||
}
|
||||
|
||||
function Harness({initialDraft, textareaInputDisabled = false, inlineEditActive = false}: HarnessProps) {
|
||||
const handleRef = useRef<ComposerHandle | null>(null);
|
||||
handleRef.current = {focus: focusSpy} as unknown as ComposerHandle;
|
||||
const editableRef = useRef<HTMLDivElement | null>(null);
|
||||
useChannelComposerDraftFocusRestore({
|
||||
handleRef,
|
||||
editableRef,
|
||||
initialDraft,
|
||||
textareaInputDisabled,
|
||||
inlineEditActive,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={editableRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
data-flx="channel.use-channel-composer-draft-focus-restore-test.harness.div"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function render(props: HarnessProps): void {
|
||||
act(() => {
|
||||
root.render(<Harness data-flx="channel.use-channel-composer-draft-focus-restore-test.harness" {...props} />);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
focusSpy = vi.fn();
|
||||
canFocusTextareaMock.mockReturnValue(true);
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
canFocusTextareaMock.mockReset();
|
||||
});
|
||||
|
||||
describe('useChannelComposerDraftFocusRestore', () => {
|
||||
test('focuses the composer when the channel is entered with a pending draft', () => {
|
||||
render({initialDraft: 'half written'});
|
||||
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('leaves focus alone when there is no pending draft', () => {
|
||||
render({initialDraft: ''});
|
||||
expect(focusSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('yields to an active inline message edit', () => {
|
||||
render({initialDraft: 'half written', inlineEditActive: true});
|
||||
expect(focusSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does nothing when composer input is disabled', () => {
|
||||
render({initialDraft: 'half written', textareaInputDisabled: true});
|
||||
expect(focusSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('respects the shared focus guard that blocks mobile, modals and popouts', () => {
|
||||
canFocusTextareaMock.mockReturnValue(false);
|
||||
render({initialDraft: 'half written'});
|
||||
expect(focusSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('restores focus only on the channel mount, not on every re-render', () => {
|
||||
render({initialDraft: 'half written'});
|
||||
render({initialDraft: 'half written'});
|
||||
render({initialDraft: 'half written more'});
|
||||
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
+36
@@ -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>
|
||||
);
|
||||
});
|
||||
+13
@@ -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;
|
||||
}
|
||||
+172
@@ -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
Reference in New Issue
Block a user