mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-22 07:24:30 +09:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49a1ffabbe | ||
|
|
2d8d130be1 | ||
|
|
d564e01518 | ||
|
|
557efe1c7d | ||
|
|
0b1ddc2695 | ||
|
|
c195e2b5de | ||
|
|
9e15194d6f | ||
|
|
275cc73f7a | ||
|
|
3224f2113a | ||
|
|
b9ea837c75 | ||
|
|
41da6b5985 | ||
|
|
9000a9f4c7 | ||
|
|
61a4e3895a | ||
|
|
86c78427d8 | ||
|
|
8beccdfe54 | ||
|
|
8f4c966d3a | ||
|
|
c424656bad | ||
|
|
4b8ce8bf63 | ||
|
|
4f88872a3c |
No files matched your search
Generated
+16
-3
@@ -2986,6 +2986,18 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nkeys"
|
||||
version = "0.4.5"
|
||||
@@ -4613,7 +4625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -5763,14 +5775,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "8.6.0"
|
||||
version = "9.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"nix",
|
||||
"typed-path",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
@@ -130,6 +130,8 @@ secrets:
|
||||
# FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64: ""
|
||||
# SMTP password for outgoing email.
|
||||
# FLUXER_EMAIL_SMTP_PASSWORD: ""
|
||||
# SMTP password of the email fallback provider.
|
||||
# FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD: ""
|
||||
# Secret that authenticates email provider webhooks.
|
||||
# FLUXER_EMAIL_WEBHOOK_SECRET: ""
|
||||
# Stripe secret key.
|
||||
|
||||
@@ -325,6 +325,18 @@ FLUXER_EMAIL_SMTP_TLS_MODE=
|
||||
# The older switch, read only while the mode above is empty. true is implicit
|
||||
# and false is opportunistic.
|
||||
#FLUXER_EMAIL_SMTP_SECURE=false
|
||||
# A second SMTP provider for mail to the listed recipient domains, comma
|
||||
# separated and matched exactly. Everything else goes through the provider
|
||||
# above. The port falls back to 587 and the mode works like the one above.
|
||||
#FLUXER_EMAIL_FALLBACK_DOMAINS=gmail.com,googlemail.com
|
||||
#FLUXER_EMAIL_FALLBACK_SMTP_HOST=
|
||||
#FLUXER_EMAIL_FALLBACK_SMTP_PORT=587
|
||||
#FLUXER_EMAIL_FALLBACK_SMTP_USERNAME=
|
||||
#FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD=
|
||||
#FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE=
|
||||
# Extra headers on every fallback message, as a JSON object of header name to
|
||||
# string value. Use single quotes so the shell keeps the JSON intact.
|
||||
#FLUXER_EMAIL_FALLBACK_SMTP_HEADERS='{"X-Example-Tracking":"off"}'
|
||||
#FLUXER_EMAIL_WEBHOOK_SECRET=
|
||||
|
||||
FLUXER_DISCOVERY_ENABLED=true
|
||||
@@ -353,12 +365,10 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_AUTO_JOIN_INVITE_CODE=
|
||||
#FLUXER_DELETION_GRACE_PERIOD_HOURS=336
|
||||
|
||||
# Report retention. A daily job deletes each report this many days after it was
|
||||
# filed, with its evidence copies and search entry, unless a legal hold is set.
|
||||
# Report retention. A daily job deletes each resolved report this many days
|
||||
# after it was resolved, with its evidence copies and search entry, unless a
|
||||
# legal hold is set. Pending reports are kept until they are resolved.
|
||||
#FLUXER_REPORT_RETENTION_DAYS=365
|
||||
# Delete resolved reports this many days after they were resolved, when that
|
||||
# comes first. Unset leaves them to FLUXER_REPORT_RETENTION_DAYS.
|
||||
#FLUXER_RESOLVED_REPORT_RETENTION_DAYS=
|
||||
# true only logs what the job would delete. Deleted evidence cannot be
|
||||
# restored, so check the "Processed report retention" log line of the worker
|
||||
# in a dry run first if you are unsure.
|
||||
|
||||
@@ -126,6 +126,13 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-}
|
||||
FLUXER_EMAIL_SMTP_TLS_MODE: ${FLUXER_EMAIL_SMTP_TLS_MODE:-}
|
||||
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-}
|
||||
FLUXER_EMAIL_FALLBACK_DOMAINS: ${FLUXER_EMAIL_FALLBACK_DOMAINS:-}
|
||||
FLUXER_EMAIL_FALLBACK_SMTP_HOST: ${FLUXER_EMAIL_FALLBACK_SMTP_HOST:-}
|
||||
FLUXER_EMAIL_FALLBACK_SMTP_PORT: ${FLUXER_EMAIL_FALLBACK_SMTP_PORT:-}
|
||||
FLUXER_EMAIL_FALLBACK_SMTP_USERNAME: ${FLUXER_EMAIL_FALLBACK_SMTP_USERNAME:-}
|
||||
FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD: ${FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD:-}
|
||||
FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE: ${FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE:-}
|
||||
FLUXER_EMAIL_FALLBACK_SMTP_HEADERS: ${FLUXER_EMAIL_FALLBACK_SMTP_HEADERS:-}
|
||||
|
||||
FLUXER_STRIPE_ENABLED: ${FLUXER_STRIPE_ENABLED:-}
|
||||
FLUXER_STRIPE_SECRET_KEY: ${FLUXER_STRIPE_SECRET_KEY:-}
|
||||
@@ -159,7 +166,6 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_DISCOVERY_MIN_MEMBER_COUNT: ${FLUXER_DISCOVERY_MIN_MEMBER_COUNT:-}
|
||||
FLUXER_DELETION_GRACE_PERIOD_HOURS: ${FLUXER_DELETION_GRACE_PERIOD_HOURS:-}
|
||||
FLUXER_REPORT_RETENTION_DAYS: ${FLUXER_REPORT_RETENTION_DAYS:-}
|
||||
FLUXER_RESOLVED_REPORT_RETENTION_DAYS: ${FLUXER_RESOLVED_REPORT_RETENTION_DAYS:-}
|
||||
FLUXER_REPORT_RETENTION_DRY_RUN: ${FLUXER_REPORT_RETENTION_DRY_RUN:-}
|
||||
FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES: ${FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES:-}
|
||||
|
||||
|
||||
Generated
+170
-4
@@ -5655,7 +5655,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Retrieves detailed information about a specific report including content, reporter, reason, and the message context and profile snapshot captured when it was filed. Requires REPORT_VIEW permission.",
|
||||
"description": "Retrieves detailed information about a specific report including content, reporter, reason, the message context and profile snapshot captured when it was filed, and its internal notes. Requires REPORT_VIEW permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -5782,7 +5782,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Deletes a report now: its record with the captured message context and profile snapshot, its stored evidence copies, its search index entry, and the duplicate-report reservation it holds for the reporter. An evidence copy that another report also uses is kept. A report under a legal hold that has not ended is refused with REPORT_UNDER_LEGAL_HOLD. This cannot be undone. Creates an audit log entry. Requires REPORT_DELETE permission.",
|
||||
"description": "Deletes a report now: its record with the captured message context and profile snapshot, its notes, its stored evidence copies, its search index entry, and the duplicate-report reservation it holds for the reporter. An evidence copy that another report also uses is kept. A report under a legal hold that has not ended is refused with REPORT_UNDER_LEGAL_HOLD. This cannot be undone. Creates an audit log entry. Requires REPORT_DELETE permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -5867,6 +5867,138 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/reports/{report_id}/notes": {
|
||||
"get": {
|
||||
"operationId": "list_admin_report_notes",
|
||||
"summary": "List report notes",
|
||||
"tags": ["Admin"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReportNoteListResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Lists the internal notes on a report, oldest first. Notes are staff only and are deleted with the report. Requires REPORT_VIEW permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "report_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "The ID of the report",
|
||||
"allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]
|
||||
},
|
||||
"description": "The ID of the report"
|
||||
}
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "annotate_admin_report",
|
||||
"summary": "Add a note to a report",
|
||||
"tags": ["Admin"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReportNoteResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Appends an internal note to a report. Notes are staff only, never change and are deleted with the report. Creates audit log entry. Requires REPORT_RESOLVE permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "report_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "The ID of the report",
|
||||
"allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]
|
||||
},
|
||||
"description": "The ID of the report"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReportNoteRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/search/index-refreshes/{job_id}": {
|
||||
"get": {
|
||||
"operationId": "get_admin_search_index_refresh",
|
||||
@@ -10969,6 +11101,34 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"ReportNoteRequest": {
|
||||
"type": "object",
|
||||
"properties": {"body": {"description": "The internal note. Staff only", "type": "string"}},
|
||||
"required": ["body"]
|
||||
},
|
||||
"ReportNoteResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"note_id": {"$ref": "#/components/schemas/SnowflakeStringType"},
|
||||
"author_id": {"$ref": "#/components/schemas/SnowflakeStringType"},
|
||||
"body": {"type": "string"},
|
||||
"created_at": {"type": "string"}
|
||||
},
|
||||
"required": ["note_id", "author_id", "body", "created_at"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ReportNoteListResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"notes": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/ReportNoteResponse"},
|
||||
"description": "The notes on the report, oldest first"
|
||||
}
|
||||
},
|
||||
"required": ["notes"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ReportLegalHoldRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -11027,14 +11187,15 @@
|
||||
},
|
||||
"outcome": {
|
||||
"description": "What happened to the reported content or account. Required for actioned, where no_violation is refused. Optional for no_violation, where it can only be no_violation. Refused for duplicate. The reporter is told it",
|
||||
"x-enumNames": ["removed", "restricted", "action_taken", "no_violation"],
|
||||
"x-enumNames": ["removed", "warned", "restricted", "action_taken", "no_violation"],
|
||||
"x-enumDescriptions": [
|
||||
"The reported content, profile field or community was removed",
|
||||
"The account responsible for the reported item was warned",
|
||||
"The reported account was restricted and the content was left up",
|
||||
"Action was taken under the rules, and the reporter is not told which",
|
||||
"The reported content or account does not break the rules and was left as it is"
|
||||
],
|
||||
"enum": ["removed", "restricted", "action_taken", "no_violation"],
|
||||
"enum": ["removed", "warned", "restricted", "action_taken", "no_violation"],
|
||||
"type": "string"
|
||||
},
|
||||
"duplicate_of": {
|
||||
@@ -11405,6 +11566,11 @@
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"notes": {
|
||||
"description": "Internal staff notes on the report, oldest first. Returned by the report detail only.",
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/ReportNoteResponse"}
|
||||
},
|
||||
"reason": {"nullable": true, "type": "string"},
|
||||
"reason_label": {"nullable": true, "type": "string"},
|
||||
"reason_highest_priority": {"nullable": true, "type": "boolean"},
|
||||
|
||||
@@ -5,8 +5,8 @@ use reqwest::Method;
|
||||
use crate::api::generated::{
|
||||
snowflake,
|
||||
types::{
|
||||
UpdateReportRequest, UpdateReportRequestOutcome, UpdateReportRequestResolution,
|
||||
UpdateReportRequestStatus,
|
||||
ReportNoteRequest, ReportNoteResponse, UpdateReportRequest, UpdateReportRequestOutcome,
|
||||
UpdateReportRequestResolution, UpdateReportRequestStatus,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -94,6 +94,20 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn annotate_report(
|
||||
&self,
|
||||
report_id: &str,
|
||||
body: String,
|
||||
) -> ApiResult<ReportNoteResponse> {
|
||||
self.send(
|
||||
Method::POST,
|
||||
&format!("/admin/reports/{}/notes", urlencoding::encode(report_id)),
|
||||
Some(&ReportNoteRequest { body }),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_report(
|
||||
&self,
|
||||
report_id: &str,
|
||||
|
||||
@@ -171,6 +171,16 @@ pub struct ReportEntry {
|
||||
pub legal_hold_until: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legal_hold_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub notes: Vec<ReportNoteEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportNoteEntry {
|
||||
pub note_id: String,
|
||||
pub author_id: String,
|
||||
pub body: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
|
||||
@@ -62,6 +62,14 @@ struct LegalHoldForm {
|
||||
clear: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NoteForm {
|
||||
#[serde(default)]
|
||||
_csrf: Option<String>,
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DeleteForm {
|
||||
#[serde(default)]
|
||||
@@ -99,6 +107,10 @@ pub fn router() -> Router<AppState> {
|
||||
"/reports/{report_id}/resolve",
|
||||
axum::routing::post(report_resolve),
|
||||
)
|
||||
.route(
|
||||
"/reports/{report_id}/notes",
|
||||
axum::routing::post(report_note),
|
||||
)
|
||||
.route(
|
||||
"/reports/{report_id}/legal-hold",
|
||||
axum::routing::post(report_legal_hold),
|
||||
@@ -215,12 +227,21 @@ async fn report_detail(
|
||||
.log_error("load report detail");
|
||||
match report {
|
||||
Some(report) => {
|
||||
let live = load_live_profile(&client, &auth.0, &report).await;
|
||||
let note_author_ids = note_author_ids(&report);
|
||||
let (live, note_authors) =
|
||||
tokio::join!(load_live_profile(&client, &auth.0, &report), async {
|
||||
client
|
||||
.lookup_users_by_ids(¬e_author_ids)
|
||||
.await
|
||||
.log_error("load report note authors")
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let markup = templates::pages::report_detail::report_detail_page(
|
||||
config,
|
||||
&auth.0,
|
||||
&report,
|
||||
&live,
|
||||
¬e_authors,
|
||||
&csrf_token,
|
||||
is_detail_fragment,
|
||||
);
|
||||
@@ -233,6 +254,17 @@ async fn report_detail(
|
||||
}
|
||||
}
|
||||
|
||||
fn note_author_ids(report: &ReportEntry) -> Vec<String> {
|
||||
let mut ids: Vec<String> = report
|
||||
.notes
|
||||
.iter()
|
||||
.map(|note| note.author_id.clone())
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
ids
|
||||
}
|
||||
|
||||
async fn load_live_profile(
|
||||
client: &AdminApiClient,
|
||||
auth: &AuthContext,
|
||||
@@ -381,6 +413,43 @@ async fn report_resolve(
|
||||
flash::redirect_with_flash(&back, flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
async fn report_note(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
Path(report_id): Path<String>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let back = format!("{}/reports/{report_id}", config.base_path);
|
||||
let form: NoteForm = match Form::from_request(request, &state).await {
|
||||
Ok(Form(f)) => f,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, report_id, "failed to parse report note form");
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error("Invalid form data"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let Some(body) = clean_string(form.body.as_deref().unwrap_or("")) else {
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error("Write a note first"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
};
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let flash = match client.annotate_report(&report_id, body).await {
|
||||
Ok(_) => FlashData::success("Note added"),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, report_id, "admin API request failed: add report note");
|
||||
FlashData::error("Failed to add the note")
|
||||
}
|
||||
};
|
||||
flash::redirect_with_flash(&back, flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
const RESOLUTION_REQUIRED: &str = "Choose a resolution";
|
||||
const OUTCOME_REQUIRED: &str = "Choose what happened to the reported content or account";
|
||||
const DUPLICATE_OF_REQUIRED: &str = "Enter the ID of the report this one repeats";
|
||||
|
||||
@@ -98,11 +98,23 @@ pub fn field_label(for_id: &str, text: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn required_mark() -> Markup {
|
||||
html! {
|
||||
span class="text-red-500 ml-0.5" { "*" }
|
||||
}
|
||||
}
|
||||
|
||||
fn required_label(for_id: &str, text: &str) -> Markup {
|
||||
html! {
|
||||
label for=(for_id) class=(FIELD_LABEL_CLASS) { (text) (required_mark()) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal_note_field(id_prefix: &str, help: &str) -> Markup {
|
||||
let id = format!("{id_prefix}-internal-note");
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
(field_label(&id, "Internal note"))
|
||||
(required_label(&id, "Internal note"))
|
||||
input type="text" id=(id) name="internal_note" required
|
||||
maxlength=(INTERNAL_NOTE_MAX_CHARS) class=(FIELD_INPUT_CLASS);
|
||||
p class=(FIELD_HELP_CLASS) { (help) }
|
||||
@@ -147,14 +159,25 @@ pub fn statement_field(id_prefix: &str, help: &str) -> Markup {
|
||||
}
|
||||
|
||||
pub fn radio_group(legend: &str, name: &str, id_prefix: &str, options: &[(&str, &str)]) -> Markup {
|
||||
checked_radio_group(legend, name, id_prefix, options, None)
|
||||
}
|
||||
|
||||
fn checked_radio_group(
|
||||
legend: &str,
|
||||
name: &str,
|
||||
id_prefix: &str,
|
||||
options: &[(&str, &str)],
|
||||
checked: Option<&str>,
|
||||
) -> Markup {
|
||||
html! {
|
||||
fieldset class="space-y-1" {
|
||||
legend class=(FIELD_LABEL_CLASS) { (legend) }
|
||||
legend class=(FIELD_LABEL_CLASS) { (legend) (required_mark()) }
|
||||
div class="flex flex-wrap gap-x-4 gap-y-1" {
|
||||
@for (value, label) in options {
|
||||
@let id = format!("{id_prefix}-{name}-{value}");
|
||||
div class="flex items-center gap-2" {
|
||||
input type="radio" id=(id) name=(name) value=(value) required
|
||||
checked[checked == Some(*value)]
|
||||
class="h-4 w-4 accent-neutral-900";
|
||||
label for=(id) class="text-sm text-neutral-900" { (label) }
|
||||
}
|
||||
@@ -181,7 +204,7 @@ pub fn reason_select(
|
||||
let id = format!("{id_prefix}-reason");
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
(field_label(&id, "Reason"))
|
||||
(required_label(&id, "Reason"))
|
||||
select id=(id) name="reason_code" required class=(FIELD_INPUT_CLASS) {
|
||||
option value="" disabled selected { "Choose a reason" }
|
||||
@for (handling, group) in HANDLING_GROUPS
|
||||
@@ -226,13 +249,14 @@ pub fn enforcement_fields(fields: &DecisionFields<'_>) -> Markup {
|
||||
let reports_id = format!("{prefix}-report-ids");
|
||||
let messages_id = format!("{prefix}-evidence");
|
||||
let rights_holder_id = format!("{prefix}-rights-holder");
|
||||
let from_report = !fields.report_ids.is_empty();
|
||||
html! {
|
||||
div class="space-y-4" data-enforcement-fields="" {
|
||||
(reason_select(prefix, fields.kind, fields.allow_restricted))
|
||||
(radio_group("How did we find this?", "source", prefix, &[
|
||||
(checked_radio_group("How did we find this?", "source", prefix, &[
|
||||
("report", "A report"),
|
||||
("own_review", "Our own review"),
|
||||
]))
|
||||
], from_report.then_some("report")))
|
||||
div class="space-y-1" {
|
||||
(radio_group("Automated tools", "automation", prefix, &[
|
||||
("none", "Not involved"),
|
||||
@@ -243,7 +267,10 @@ pub fn enforcement_fields(fields: &DecisionFields<'_>) -> Markup {
|
||||
}
|
||||
}
|
||||
div class="space-y-1" {
|
||||
(field_label(&reports_id, "Reports"))
|
||||
label for=(reports_id) class=(FIELD_LABEL_CLASS) {
|
||||
"Reports"
|
||||
span data-show-for-source="report" hidden[!from_report] { (required_mark()) }
|
||||
}
|
||||
textarea id=(reports_id) name="report_ids" rows="2"
|
||||
placeholder="One report ID per line" class=(FIELD_INPUT_CLASS) {
|
||||
(fields.report_ids.join("\n"))
|
||||
@@ -257,13 +284,19 @@ pub fn enforcement_fields(fields: &DecisionFields<'_>) -> Markup {
|
||||
class=(FIELD_INPUT_CLASS) {
|
||||
(messages.join("\n"))
|
||||
}
|
||||
p class=(FIELD_HELP_CLASS) data-hide-for-codes=(restricted_codes) {
|
||||
"Messages this user sent in a linked report are included automatically. Without any messages, add a note to the user."
|
||||
}
|
||||
p class=(FIELD_HELP_CLASS) data-show-for-codes=(restricted_codes) hidden {
|
||||
"Messages this user sent in a linked report are included automatically. At least one message is required."
|
||||
}
|
||||
}
|
||||
}
|
||||
div data-hide-for-codes=(restricted_codes) {
|
||||
(statement_field(prefix, "Shown to the user. Never name or describe other people. Not sent for child safety or violent extremism decisions."))
|
||||
}
|
||||
div class="space-y-1" data-show-for-codes=(COPYRIGHT_REASON_CODE) hidden {
|
||||
(field_label(&rights_holder_id, "Rights holder or right concerned"))
|
||||
(required_label(&rights_holder_id, "Rights holder or right concerned"))
|
||||
input type="text" id=(rights_holder_id) name="rights_holder" required disabled
|
||||
maxlength=(RIGHTS_HOLDER_MAX_CHARS) class=(FIELD_INPUT_CLASS);
|
||||
p class=(FIELD_HELP_CLASS) {
|
||||
@@ -626,7 +659,43 @@ mod tests {
|
||||
assert!(html.contains(marker), "missing {marker}\n{html}");
|
||||
}
|
||||
assert!(!html.contains(r#"name="automation" value="automated""#));
|
||||
assert!(!html.contains(r#"checked"#));
|
||||
assert_eq!(html.matches("checked").count(), 1);
|
||||
assert!(html.contains(r#"name="source" value="report" required checked"#));
|
||||
assert!(html.contains(r#"<span data-show-for-source="report"><span"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fieldset_without_a_report_leaves_the_source_open() {
|
||||
let html = enforcement_fields(&DecisionFields {
|
||||
id_prefix: "limit",
|
||||
kind: EnforcementKindType::FeatureLimit,
|
||||
report_ids: &[],
|
||||
messages: Some(&[]),
|
||||
user_flags: None,
|
||||
allow_restricted: true,
|
||||
})
|
||||
.into_string();
|
||||
assert!(!html.contains("checked"));
|
||||
assert!(html.contains(r#"<span data-show-for-source="report" hidden><span"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_fields_carry_a_mark() {
|
||||
let html = render(EnforcementKindType::FeatureLimit, 0);
|
||||
let mark = r#"<span class="text-red-500 ml-0.5">*</span>"#;
|
||||
for label in [
|
||||
"Reason",
|
||||
"How did we find this?",
|
||||
"Automated tools",
|
||||
"Internal note",
|
||||
"Rights holder or right concerned",
|
||||
] {
|
||||
assert!(html.contains(&format!("{label}{mark}")), "unmarked {label}");
|
||||
}
|
||||
for label in ["Messages<", "Note to the user<"] {
|
||||
assert!(html.contains(label), "marked {label}");
|
||||
}
|
||||
assert_eq!(html.matches(mark).count(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -38,6 +38,7 @@ pub struct Message {
|
||||
pub channel_nsfw: Option<bool>,
|
||||
pub channel_content_warning_level: Option<i32>,
|
||||
pub channel_content_warning_text: Option<String>,
|
||||
pub guild_id: Option<String>,
|
||||
pub guild_nsfw: Option<bool>,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub missing_attachments: Vec<MissingAttachment>,
|
||||
@@ -85,6 +86,7 @@ fn message_from_value(value: &Value) -> Message {
|
||||
channel_content_warning_text: value["channel_content_warning_text"]
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned),
|
||||
guild_id: value_id(&value["guild_id"]),
|
||||
guild_nsfw: value["guild_nsfw"].as_bool(),
|
||||
attachments,
|
||||
missing_attachments,
|
||||
|
||||
@@ -110,6 +110,48 @@ fn render_missing_attachments(msg: &Message) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_button(value: &str, label: &str) -> Markup {
|
||||
html! {
|
||||
button type="button"
|
||||
class="cursor-pointer text-blue-600 hover:underline"
|
||||
data-copy-value=(value)
|
||||
onclick="window.__adminCopyToClipboard && window.__adminCopyToClipboard(this.dataset.copyValue, this, 'Copied')" {
|
||||
(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn message_meta(msg: &Message, jump_link: Option<&str>, take_action: Option<&str>) -> Markup {
|
||||
html! {
|
||||
div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-neutral-400 text-xs" {
|
||||
span class="select-all" { (msg.id) }
|
||||
@if let Some(href) = take_action {
|
||||
a href=(href) class="text-blue-600 hover:underline" { "Take action" }
|
||||
}
|
||||
@if let Some(link) = jump_link {
|
||||
(copy_button(link, "Copy link"))
|
||||
}
|
||||
(copy_button(&msg.id, "Copy message ID"))
|
||||
@if !msg.channel_id.is_empty() {
|
||||
(copy_button(&msg.channel_id, "Copy channel ID"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn message_jump_link(web_app_endpoint: &str, msg: &Message) -> Option<String> {
|
||||
if msg.channel_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"{}/channels/{}/{}/{}",
|
||||
web_app_endpoint.trim_end_matches('/'),
|
||||
msg.guild_id.as_deref().unwrap_or("@me"),
|
||||
msg.channel_id,
|
||||
msg.id
|
||||
))
|
||||
}
|
||||
|
||||
fn message_row(
|
||||
base_path: &str,
|
||||
avatar_url: &str,
|
||||
@@ -117,7 +159,7 @@ fn message_row(
|
||||
include_delete: bool,
|
||||
is_highlighted: bool,
|
||||
is_grouped: bool,
|
||||
take_action: Option<&str>,
|
||||
meta: &Markup,
|
||||
) -> Markup {
|
||||
let hover = if is_highlighted {
|
||||
" hover:bg-amber-100"
|
||||
@@ -151,12 +193,7 @@ fn message_row(
|
||||
(render_other_attachments(msg, has_content_or_images))
|
||||
}
|
||||
(render_missing_attachments(msg))
|
||||
div class="mt-1 text-neutral-400 text-xs" {
|
||||
span class="" { (msg.id) }
|
||||
@if let Some(href) = take_action {
|
||||
a href=(href) class="ml-2 text-blue-600 hover:underline" { "Take action" }
|
||||
}
|
||||
}
|
||||
(meta)
|
||||
@if include_delete && !has_images {
|
||||
div class="absolute top-0 right-2 hidden group-hover:block" {
|
||||
button type="button"
|
||||
@@ -237,12 +274,7 @@ fn message_row(
|
||||
(render_other_attachments(msg, has_content_or_images))
|
||||
}
|
||||
(render_missing_attachments(msg))
|
||||
div class="mt-1 text-neutral-400 text-xs" {
|
||||
span class="" { (msg.id) }
|
||||
@if let Some(href) = take_action {
|
||||
a href=(href) class="ml-2 text-blue-600 hover:underline" { "Take action" }
|
||||
}
|
||||
}
|
||||
(meta)
|
||||
}
|
||||
@if include_delete && !has_images {
|
||||
div class="absolute top-1 right-2 hidden group-hover:block" {
|
||||
@@ -287,10 +319,12 @@ pub fn message_list(
|
||||
action.report_id,
|
||||
Some((&msg.channel_id, &msg.id)),
|
||||
));
|
||||
@let jump_link = message_jump_link(&config.web_app_endpoint, msg);
|
||||
@let meta = message_meta(msg, jump_link.as_deref(), take_action_href.as_deref());
|
||||
(message_row(
|
||||
base_path, &avatar_url, msg,
|
||||
include_delete, is_highlighted, is_grouped,
|
||||
take_action_href.as_deref(),
|
||||
&meta,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -319,3 +353,50 @@ const MESSAGE_DELETE_PREFILL_SCRIPT: &str = r#"(function() {
|
||||
form.querySelector('[name="basis"]').focus({preventScroll: true});
|
||||
});
|
||||
})();"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::templates::components::message_data::ordered_messages;
|
||||
use serde_json::json;
|
||||
|
||||
fn message(guild_id: serde_json::Value, channel_id: serde_json::Value) -> Message {
|
||||
ordered_messages(&[json!({
|
||||
"id": "1560373081531219968",
|
||||
"channel_id": channel_id,
|
||||
"guild_id": guild_id,
|
||||
"author_id": "1",
|
||||
"author_username": "someone",
|
||||
})])
|
||||
.remove(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_link_uses_guild_id_and_web_app_endpoint() {
|
||||
let msg = message(json!("42"), json!("7"));
|
||||
assert_eq!(
|
||||
message_jump_link("https://web.fluxer.example/", &msg).as_deref(),
|
||||
Some("https://web.fluxer.example/channels/42/7/1560373081531219968")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_link_falls_back_to_me_for_dms() {
|
||||
let msg = message(json!(null), json!("7"));
|
||||
assert_eq!(
|
||||
message_jump_link("https://web.fluxer.example", &msg).as_deref(),
|
||||
Some("https://web.fluxer.example/channels/@me/7/1560373081531219968")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_without_channel_skips_link_and_channel_copy() {
|
||||
let msg = message(json!(null), json!(null));
|
||||
assert_eq!(message_jump_link("https://web.fluxer.example", &msg), None);
|
||||
let html = message_meta(&msg, None, None).into_string();
|
||||
assert!(html.contains(r#"data-copy-value="1560373081531219968""#));
|
||||
assert!(html.contains("Copy message ID"));
|
||||
assert!(!html.contains("Copy link"));
|
||||
assert!(!html.contains("Copy channel ID"));
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ pub mod report_category;
|
||||
pub mod report_webhook;
|
||||
pub mod resource_link;
|
||||
pub mod section_card;
|
||||
pub mod staff_notes;
|
||||
pub mod table;
|
||||
pub mod tooltip;
|
||||
pub mod user_display;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
templates::components::{
|
||||
enforcement_fields::{FIELD_HELP_CLASS, FIELD_INPUT_CLASS, field_label},
|
||||
form::{csrf_input, form_actions, submit_button},
|
||||
section_card::section_card_simple,
|
||||
},
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
const NOTE_MAX_CHARS: usize = 2000;
|
||||
|
||||
pub struct StaffNote<'a> {
|
||||
pub note_id: &'a str,
|
||||
pub author: Markup,
|
||||
pub body: &'a str,
|
||||
pub created_at: &'a str,
|
||||
}
|
||||
|
||||
pub struct StaffNoteForm<'a> {
|
||||
pub action: String,
|
||||
pub refresh_target: &'a str,
|
||||
pub field_id: String,
|
||||
pub help: &'a str,
|
||||
pub csrf_token: &'a str,
|
||||
}
|
||||
|
||||
pub fn staff_notes_section(notes: &[StaffNote], form: Option<StaffNoteForm>) -> Markup {
|
||||
html! {
|
||||
(section_card_simple("Notes", html! {
|
||||
div class="space-y-4" {
|
||||
@if notes.is_empty() {
|
||||
p class="text-sm text-neutral-500" { "No notes." }
|
||||
} @else {
|
||||
ol class="space-y-3" {
|
||||
@for note in notes {
|
||||
li class="space-y-1" data-note=(note.note_id) {
|
||||
p class="text-xs text-neutral-500" {
|
||||
(note.author)
|
||||
" on " (format_admin_timestamp(note.created_at))
|
||||
}
|
||||
p class="whitespace-pre-wrap break-words text-sm text-neutral-900" { (note.body) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@if let Some(form) = form {
|
||||
form method="post" action=(form.action)
|
||||
data-admin-refresh-on-success=(form.refresh_target) {
|
||||
(csrf_input(form.csrf_token))
|
||||
div class="space-y-3" {
|
||||
div class="space-y-1" {
|
||||
(field_label(&form.field_id, "Add a note"))
|
||||
textarea id=(form.field_id) name="body" rows="2" required maxlength=(NOTE_MAX_CHARS)
|
||||
class=(FIELD_INPUT_CLASS) {}
|
||||
p class=(FIELD_HELP_CLASS) { (form.help) }
|
||||
}
|
||||
(form_actions(submit_button("Add note")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::user_tag::user_tag;
|
||||
use crate::{api::types::AdminUser, utils::user_tag::user_tag};
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub const SYSTEM_USER_ID: &str = "0";
|
||||
|
||||
pub fn format_user_display(
|
||||
global_name: Option<&str>,
|
||||
@@ -16,3 +19,29 @@ pub fn format_user_display(
|
||||
_ => "Unknown".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_name(users: &[AdminUser], id: &str) -> String {
|
||||
if id == SYSTEM_USER_ID {
|
||||
return "System".to_owned();
|
||||
}
|
||||
users.iter().find(|user| user.id == id).map_or_else(
|
||||
|| id.to_owned(),
|
||||
|user| {
|
||||
format_user_display(
|
||||
user.global_name.as_deref(),
|
||||
Some(&user.username),
|
||||
Some(&user.discriminator),
|
||||
user.bot,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn user_link(base: &str, users: &[AdminUser], id: &str) -> Markup {
|
||||
if id == SYSTEM_USER_ID {
|
||||
return html! { "System" };
|
||||
}
|
||||
html! {
|
||||
a href={(base) "/users/" (id)} class="text-blue-600 hover:underline" { (user_name(users, id)) }
|
||||
}
|
||||
}
|
||||
@@ -663,7 +663,7 @@ pub const ENFORCEMENT_FIELDS_SCRIPT: &str = r#"
|
||||
syncReason(form, control.value);
|
||||
} else if (control instanceof HTMLSelectElement && (control.name === 'resolution' || control.name === 'duration_hours')) {
|
||||
syncChoice(form, control.name, control.value);
|
||||
} else if (control instanceof HTMLInputElement && control.type === 'radio' && (control.name === 'basis' || control.name === 'limit')) {
|
||||
} else if (control instanceof HTMLInputElement && control.type === 'radio' && (control.name === 'basis' || control.name === 'limit' || control.name === 'source')) {
|
||||
syncChoice(form, control.name, control.value);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,7 +29,8 @@ pub fn action_badge_variant(action: &str) -> BadgeVariant {
|
||||
| "set_acls"
|
||||
| "update_settings"
|
||||
| "annotate_ban"
|
||||
| "annotate_enforcement_decision" => BadgeVariant::Info,
|
||||
| "annotate_enforcement_decision"
|
||||
| "annotate_report" => BadgeVariant::Info,
|
||||
"delete_message" | "warn_user" => BadgeVariant::Warning,
|
||||
_ => BadgeVariant::Default,
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ use crate::{
|
||||
form::{csrf_input, form_actions, submit_button},
|
||||
page_container::{page_header, page_header_with_back},
|
||||
section_card::section_card_simple,
|
||||
user_display::format_user_display,
|
||||
staff_notes::{StaffNote, StaffNoteForm, staff_notes_section},
|
||||
user_display::{SYSTEM_USER_ID, user_link},
|
||||
},
|
||||
layout::admin_layout,
|
||||
},
|
||||
@@ -31,8 +32,6 @@ use crate::{
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
const SYSTEM_USER_ID: &str = "0";
|
||||
const NOTE_MAX_CHARS: usize = 2000;
|
||||
const CORRECTION_LINE: &str = "If this decision was reported to an authority, file a correction.";
|
||||
const DECISIONS_PAGE_KEY: &str = "decisions";
|
||||
const PERMANENT_SUSPENSION_THRESHOLD_YEARS: i32 = 50;
|
||||
@@ -223,32 +222,6 @@ pub fn end_decision_form(form: &EndDecisionForm<'_>) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn user_name(users: &[AdminUser], id: &str) -> String {
|
||||
if id == SYSTEM_USER_ID {
|
||||
return "System".to_owned();
|
||||
}
|
||||
users.iter().find(|user| user.id == id).map_or_else(
|
||||
|| id.to_owned(),
|
||||
|user| {
|
||||
format_user_display(
|
||||
user.global_name.as_deref(),
|
||||
Some(&user.username),
|
||||
Some(&user.discriminator),
|
||||
user.bot,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn user_link(base: &str, users: &[AdminUser], id: &str) -> Markup {
|
||||
if id == SYSTEM_USER_ID {
|
||||
return html! { "System" };
|
||||
}
|
||||
html! {
|
||||
a href={(base) "/users/" (id)} class="text-blue-600 hover:underline" { (user_name(users, id)) }
|
||||
}
|
||||
}
|
||||
|
||||
fn field(label: &str, value: Markup) -> Markup {
|
||||
html! {
|
||||
div class="flex min-w-0 flex-col gap-1" {
|
||||
@@ -527,44 +500,29 @@ fn notes_section(
|
||||
can_annotate: bool,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let note_id = format!("note-{}", decision.decision_id.as_str());
|
||||
html! {
|
||||
(section_card_simple("Notes", html! {
|
||||
div class="space-y-4" {
|
||||
@if decision.notes.is_empty() {
|
||||
p class="text-sm text-neutral-500" { "No notes." }
|
||||
} @else {
|
||||
ol class="space-y-3" {
|
||||
@for note in &decision.notes {
|
||||
li class="space-y-1" data-note=(note.note_id.as_str()) {
|
||||
p class="text-xs text-neutral-500" {
|
||||
(user_link(base, users, ¬e.author_id))
|
||||
" on " (format_admin_timestamp(¬e.created_at))
|
||||
}
|
||||
p class="whitespace-pre-wrap break-words text-sm text-neutral-900" { (note.body) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@if can_annotate {
|
||||
form method="post"
|
||||
action={(base) "/decisions/" (decision.decision_id.as_str()) "?action=add_note"}
|
||||
data-admin-refresh-on-success="#decision-detail" {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-3" {
|
||||
div class="space-y-1" {
|
||||
(field_label(¬e_id, "Add a note"))
|
||||
textarea id=(note_id) name="body" rows="2" required maxlength=(NOTE_MAX_CHARS)
|
||||
class=(FIELD_INPUT_CLASS) {}
|
||||
p class=(FIELD_HELP_CLASS) { "Staff only. Notes cannot be changed and expire after a year." }
|
||||
}
|
||||
(form_actions(submit_button("Add note")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
let notes: Vec<StaffNote> = decision
|
||||
.notes
|
||||
.iter()
|
||||
.map(|note| StaffNote {
|
||||
note_id: note.note_id.as_str(),
|
||||
author: user_link(base, users, ¬e.author_id),
|
||||
body: ¬e.body,
|
||||
created_at: ¬e.created_at,
|
||||
})
|
||||
.collect();
|
||||
staff_notes_section(
|
||||
¬es,
|
||||
can_annotate.then(|| StaffNoteForm {
|
||||
action: format!(
|
||||
"{base}/decisions/{}?action=add_note",
|
||||
decision.decision_id.as_str()
|
||||
),
|
||||
refresh_target: "#decision-detail",
|
||||
field_id: format!("note-{}", decision.decision_id.as_str()),
|
||||
help: "Staff only. Notes cannot be changed and expire after a year.",
|
||||
csrf_token,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn decision_user_ids(decision: &EnforcementDecisionResponse) -> Vec<String> {
|
||||
|
||||
@@ -41,6 +41,8 @@ use crate::{
|
||||
},
|
||||
resource_link::{ResourceType, resource_link},
|
||||
section_card::section_card,
|
||||
staff_notes::{StaffNote, StaffNoteForm, staff_notes_section},
|
||||
user_display::user_link,
|
||||
},
|
||||
layout::admin_layout,
|
||||
},
|
||||
@@ -601,13 +603,14 @@ fn resolve_report_form(base: &str, report_id: &str, csrf_token: &str) -> Markup
|
||||
class={(FORM_CONTROL_CLASS) " h-8 appearance-none px-3 py-1.5 pr-10"} {
|
||||
option value="" selected disabled { "Choose one" }
|
||||
option value="removed" { "Content removed" }
|
||||
option value="warned" { "Warning given" }
|
||||
option value="restricted" { "Account restricted" }
|
||||
option value="action_taken" { "Other action taken" }
|
||||
}
|
||||
(select_chevron())
|
||||
}
|
||||
p class="text-neutral-500 text-xs" {
|
||||
"What happened to the reported content or account. The reporter is told it. Other action taken, such as a warning, tells them we acted but not how."
|
||||
"What happened to the reported content or account. The reporter is told it. Warning given and other action taken tell them we acted but not how."
|
||||
}
|
||||
}
|
||||
fieldset class="flex flex-col gap-3" data-show-for-resolution="duplicate" hidden disabled {
|
||||
@@ -943,6 +946,36 @@ fn snapshot_guild_group(
|
||||
}
|
||||
}
|
||||
|
||||
fn notes_card(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
report: &ReportEntry,
|
||||
note_authors: &[AdminUser],
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let notes: Vec<StaffNote> = report
|
||||
.notes
|
||||
.iter()
|
||||
.map(|note| StaffNote {
|
||||
note_id: ¬e.note_id,
|
||||
author: user_link(base, note_authors, ¬e.author_id),
|
||||
body: ¬e.body,
|
||||
created_at: ¬e.created_at,
|
||||
})
|
||||
.collect();
|
||||
staff_notes_section(
|
||||
¬es,
|
||||
has_acl(auth, acl::REPORT_RESOLVE).then(|| StaffNoteForm {
|
||||
action: format!("{base}/reports/{}/notes", report.report_id),
|
||||
refresh_target: "#report-detail",
|
||||
field_id: format!("note-{}", report.report_id),
|
||||
help: "Staff only. Never shown to the reporter or the reported account. Notes cannot be changed and are deleted with the report.",
|
||||
csrf_token,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn legal_hold_card(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
@@ -1040,7 +1073,7 @@ fn delete_report_card(
|
||||
(section_card(Some("Delete Report"), None, None, html! {
|
||||
div class="flex flex-col gap-3" data-report-delete=(if held { "held" } else { "available" }) {
|
||||
p class="text-neutral-500 text-sm" {
|
||||
"Deletes the report now, with its message context, profile snapshot, stored evidence and search entry. \
|
||||
"Deletes the report now, with its message context, profile snapshot, notes, stored evidence and search entry. \
|
||||
Evidence that another report uses is kept. This cannot be undone."
|
||||
}
|
||||
@if held {
|
||||
@@ -1128,6 +1161,7 @@ pub fn report_detail_page(
|
||||
auth: &AuthContext,
|
||||
report: &ReportEntry,
|
||||
live: &LiveProfile,
|
||||
note_authors: &[AdminUser],
|
||||
csrf_token: &str,
|
||||
is_htmx: bool,
|
||||
) -> Markup {
|
||||
@@ -1148,6 +1182,7 @@ pub fn report_detail_page(
|
||||
(profile_snapshot_section(config, report, live))
|
||||
(message_context_section(config, report, can_take_action(admin_acls(auth))))
|
||||
(additional_info_section(report))
|
||||
(notes_card(config, auth, report, note_authors, csrf_token))
|
||||
}
|
||||
div class="space-y-6" {
|
||||
(status_card(config, report))
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::{
|
||||
enforcement_fields::{
|
||||
BasisFields, DecisionFields, FIELD_HELP_CLASS, FIELD_INPUT_CLASS,
|
||||
FIELD_LABEL_CLASS, basis_fields, deletion_days_field, enforcement_fields,
|
||||
field_label, internal_note_field, network_block_fields,
|
||||
field_label, internal_note_field, network_block_fields, required_mark,
|
||||
},
|
||||
form::{checkbox, csrf_input, danger_button, form_actions, submit_button},
|
||||
page_container::card_with_header,
|
||||
@@ -550,7 +550,7 @@ fn limit_card(base: &str, user: &AdminUser, csrf_token: &str, prefill: &Decision
|
||||
allow_restricted: true,
|
||||
}))
|
||||
fieldset class="space-y-1" {
|
||||
legend class=(FIELD_LABEL_CLASS) { "Limit" }
|
||||
legend class=(FIELD_LABEL_CLASS) { "Limit" (required_mark()) }
|
||||
div class="flex flex-wrap gap-x-4 gap-y-1" {
|
||||
@for limit in ENFORCEMENT_LIMITS {
|
||||
@let id = format!("limit-{}", limit.value);
|
||||
|
||||
@@ -466,6 +466,16 @@ async fn moderation_tab_without_a_restriction_offers_suspend_and_warn_with_prefi
|
||||
assert!(suspend.contains(&format!(">{REPORT_ID}</textarea>")));
|
||||
assert!(suspend.contains(&format!(">{CHANNEL_ID}/{MESSAGE_ID}</textarea>")));
|
||||
assert!(section(&html, "?action=warn").contains("Send warning"));
|
||||
let limit = between(&html, "?action=limit_features", "?action=warn");
|
||||
for expected in [
|
||||
format!(">{REPORT_ID}</textarea>"),
|
||||
format!(">{CHANNEL_ID}/{MESSAGE_ID}</textarea>"),
|
||||
r#"name="source" value="report" required checked"#.to_owned(),
|
||||
r#"Limit<span class="text-red-500 ml-0.5">*</span></legend>"#.to_owned(),
|
||||
r#"Internal note<span class="text-red-500 ml-0.5">*</span></label>"#.to_owned(),
|
||||
] {
|
||||
assert!(limit.contains(&expected), "missing {expected}\n{limit}");
|
||||
}
|
||||
}
|
||||
|
||||
fn between<'a>(html: &'a str, start: &str, end: &str) -> &'a str {
|
||||
@@ -509,6 +519,7 @@ async fn moderation_tab_offers_close_and_limit_features() {
|
||||
assert!(limit.contains(expected), "missing {expected}\n{limit}");
|
||||
}
|
||||
assert!(!limit.contains(r#"<option value="11""#));
|
||||
assert!(!limit.contains("checked"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -140,6 +140,128 @@ async fn report_legal_hold_form_places_and_clears_the_hold() {
|
||||
assert_eq!(received.lock().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_notes_show_the_author_and_time_and_the_form_adds_one() {
|
||||
let received = Arc::new(Mutex::new(Vec::<Value>::new()));
|
||||
let sink = received.clone();
|
||||
let api = Router::new()
|
||||
.route(
|
||||
"/admin/reports/1800000000000000001",
|
||||
routing::get(|| async { json_response(report_with_note()) }),
|
||||
)
|
||||
.route(
|
||||
"/admin/reports/1800000000000000001/notes",
|
||||
routing::post(move |Json(body): Json<Value>| {
|
||||
let sink = sink.clone();
|
||||
async move {
|
||||
sink.lock().unwrap().push(body.clone());
|
||||
json_response(json!({
|
||||
"note_id": "1950000000000000002",
|
||||
"author_id": "1500000000000000000",
|
||||
"body": body["body"],
|
||||
"created_at": "2026-05-27T08:00:00.000Z"
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/admin/users",
|
||||
routing::get(|| async {
|
||||
json_response(json!({ "users": [user("1500000000000000000", "AdminUser")] }))
|
||||
}),
|
||||
)
|
||||
.fallback(mock_api);
|
||||
let app = setup_with_api(api).await;
|
||||
let (headers, page) = get_with_headers(&app, "/reports/1800000000000000001", &[]).await;
|
||||
let note_start = page
|
||||
.find(r#"data-note="1950000000000000001""#)
|
||||
.unwrap_or_else(|| panic!("missing note\n{page}"));
|
||||
let note = &page[note_start..page[note_start..].find("</li>").unwrap() + note_start];
|
||||
assert!(
|
||||
note.contains(r#"href="/users/1500000000000000000""#),
|
||||
"{note}"
|
||||
);
|
||||
assert!(note.contains("AdminUser"), "{note}");
|
||||
assert!(note.contains("May 26, 2026, 1:30 PM UTC"), "{note}");
|
||||
assert!(
|
||||
note.contains("Reporter sent more context by email"),
|
||||
"{note}"
|
||||
);
|
||||
let csrf_token = csrf_cookie(&headers)
|
||||
.unwrap_or_else(|| panic!("report page did not set csrf_token cookie\n{page}"));
|
||||
assert_form_has_csrf(&page, "/reports/1800000000000000001/notes", &csrf_token);
|
||||
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
|
||||
let post = |body: String| {
|
||||
let app = &app;
|
||||
let cookie = cookie.clone();
|
||||
async move {
|
||||
let (status, headers, text) = post_form_with_headers(
|
||||
app,
|
||||
"/reports/1800000000000000001/notes",
|
||||
&[
|
||||
("HX-Request", "true"),
|
||||
("HX-Target", "flash-container"),
|
||||
("Cookie", &cookie),
|
||||
],
|
||||
&body,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT, "{text}");
|
||||
headers
|
||||
.get("X-Fluxer-Admin-Toast")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_else(|| panic!("missing toast header\n{text}"))
|
||||
.to_owned()
|
||||
}
|
||||
};
|
||||
|
||||
let toast = post(format!(
|
||||
"_csrf={csrf_token}&body=++Waiting+on+the+guild+owner++"
|
||||
))
|
||||
.await;
|
||||
assert!(toast.contains("success"), "{toast}");
|
||||
assert!(toast.contains("Note added"), "{toast}");
|
||||
let toast = post(format!("_csrf={csrf_token}&body=+")).await;
|
||||
assert!(toast.contains("error"), "{toast}");
|
||||
assert!(toast.contains("Write a note first"), "{toast}");
|
||||
assert_eq!(
|
||||
*received.lock().unwrap(),
|
||||
vec![json!({"body": "Waiting on the guild owner"})]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_notes_hide_the_form_without_report_resolve() {
|
||||
let api = Router::new()
|
||||
.route(
|
||||
"/admin/users/@me",
|
||||
routing::get(|| async {
|
||||
let mut admin = user("1500000000000000000", "AdminUser");
|
||||
admin["acls"] = json!(["report:view"]);
|
||||
json_response(json!({ "user": admin }))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/admin/reports/1800000000000000001",
|
||||
routing::get(|| async { json_response(report_with_note()) }),
|
||||
)
|
||||
.fallback(mock_api);
|
||||
let app = setup_with_api(api).await;
|
||||
let (_, page) = get_with_headers(&app, "/reports/1800000000000000001", &[]).await;
|
||||
assert!(
|
||||
page.contains("Reporter sent more context by email"),
|
||||
"{page}"
|
||||
);
|
||||
assert!(
|
||||
page.contains(r#"href="/users/1500000000000000000""#),
|
||||
"{page}"
|
||||
);
|
||||
assert!(
|
||||
!page.contains(r#"action="/reports/1800000000000000001/notes""#),
|
||||
"{page}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_delete_form_confirms_sends_the_reason_and_toasts_a_held_report() {
|
||||
let received = Arc::new(Mutex::new(Vec::<Option<String>>::new()));
|
||||
@@ -746,6 +868,17 @@ fn searched_report() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn report_with_note() -> Value {
|
||||
let mut report = searched_report();
|
||||
report["notes"] = json!([{
|
||||
"note_id": "1950000000000000001",
|
||||
"author_id": "1500000000000000000",
|
||||
"body": "Reporter sent more context by email",
|
||||
"created_at": "2026-05-26T13:30:00.000Z"
|
||||
}]);
|
||||
report
|
||||
}
|
||||
|
||||
fn report_reasons() -> Value {
|
||||
json!({
|
||||
"reasons": [
|
||||
|
||||
@@ -186,6 +186,8 @@ async fn report_resolve_sends_notify_reporter_from_the_checkbox() {
|
||||
assert_eq!(duplicate.body["notify_reporter"], json!(true));
|
||||
assert!(duplicate.body.get("outcome").is_none());
|
||||
assert_eq!(duplicate.body["duplicate_of"], json!("1400000000000000001"));
|
||||
let warned = submit(&uri, "resolution=actioned&outcome=warned").await;
|
||||
assert_eq!(warned.body["outcome"], json!("warned"));
|
||||
let other_action = submit(&uri, "resolution=actioned&outcome=action_taken").await;
|
||||
assert_eq!(other_action.body["outcome"], json!("action_taken"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DomainRoutedEmailProvider} from '@pkgs/email/src/DomainRoutedEmailProvider';
|
||||
import type {EmailMessage} from '@pkgs/email/src/EmailProviderTypes';
|
||||
import {EmailService} from '@pkgs/email/src/EmailService';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
function message(to: string): EmailMessage {
|
||||
return {to, from: {email: '[email protected]', name: 'Example'}, subject: 'Hello', text: 'Body'};
|
||||
}
|
||||
|
||||
describe('DomainRoutedEmailProvider', () => {
|
||||
it.each([
|
||||
{to: '[email protected]', route: 'fallback'},
|
||||
{to: '[email protected]', route: 'fallback'},
|
||||
{to: '[email protected]', route: 'fallback'},
|
||||
{to: '[email protected]', route: 'primary'},
|
||||
{to: '[email protected]', route: 'primary'},
|
||||
{to: '[email protected]', route: 'primary'},
|
||||
{to: '"[email protected]"@example.com', route: 'primary'},
|
||||
])('sends $to through the $route provider', async ({to, route}) => {
|
||||
const primary = {sendEmail: vi.fn().mockResolvedValue(true)};
|
||||
const fallback = {sendEmail: vi.fn().mockResolvedValue(false)};
|
||||
const provider = new DomainRoutedEmailProvider(primary, fallback, ['gmail.com', 'googlemail.com']);
|
||||
const sent = message(to);
|
||||
const result = await provider.sendEmail(sent);
|
||||
const [chosen, other] = route === 'fallback' ? [fallback, primary] : [primary, fallback];
|
||||
expect(chosen.sendEmail).toHaveBeenCalledWith(sent);
|
||||
expect(other.sendEmail).not.toHaveBeenCalled();
|
||||
expect(result).toBe(route === 'primary');
|
||||
});
|
||||
|
||||
it('sends every address through the fallback provider when the domains are *', async () => {
|
||||
const primary = {sendEmail: vi.fn().mockResolvedValue(true)};
|
||||
const fallback = {sendEmail: vi.fn().mockResolvedValue(true)};
|
||||
const provider = new DomainRoutedEmailProvider(primary, fallback, ['*']);
|
||||
await provider.sendEmail(message('[email protected]'));
|
||||
await provider.sendEmail(message('[email protected]'));
|
||||
expect(fallback.sendEmail).toHaveBeenCalledTimes(2);
|
||||
expect(primary.sendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never reaches either provider for a bounced address', async () => {
|
||||
const primary = {sendEmail: vi.fn().mockResolvedValue(true)};
|
||||
const fallback = {sendEmail: vi.fn().mockResolvedValue(true)};
|
||||
const service = new EmailService(
|
||||
{
|
||||
enabled: true,
|
||||
fromEmail: '[email protected]',
|
||||
fromName: 'Example',
|
||||
appBaseUrl: 'https://example.com',
|
||||
appealsEmail: null,
|
||||
safetyEmail: null,
|
||||
supportEmail: null,
|
||||
productName: 'Example',
|
||||
},
|
||||
new DomainRoutedEmailProvider(primary, fallback, ['gmail.com']),
|
||||
{isEmailBounced: async () => true},
|
||||
);
|
||||
expect(await service.sendPasswordResetEmail('[email protected]', 'user', 'token')).toBe(false);
|
||||
expect(primary.sendEmail).not.toHaveBeenCalled();
|
||||
expect(fallback.sendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createLogger} from '@fluxer/logger/src/Logger';
|
||||
import type {EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
|
||||
|
||||
const logger = createLogger('@pkgs/email/src/DomainRoutedEmailProvider');
|
||||
|
||||
export class DomainRoutedEmailProvider implements IEmailProvider {
|
||||
private readonly primary: IEmailProvider;
|
||||
private readonly fallback: IEmailProvider;
|
||||
private readonly fallbackDomains: ReadonlySet<string>;
|
||||
|
||||
constructor(primary: IEmailProvider, fallback: IEmailProvider, fallbackDomains: ReadonlyArray<string>) {
|
||||
this.primary = primary;
|
||||
this.fallback = fallback;
|
||||
this.fallbackDomains = new Set(fallbackDomains);
|
||||
}
|
||||
|
||||
async sendEmail(message: EmailMessage): Promise<boolean> {
|
||||
const domain = message.to
|
||||
.slice(message.to.lastIndexOf('@') + 1)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const useFallback = this.fallbackDomains.has('*') || this.fallbackDomains.has(domain);
|
||||
logger.debug({domain, provider: useFallback ? 'fallback' : 'primary'}, 'Routing email');
|
||||
return (useFallback ? this.fallback : this.primary).sendEmail(message);
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,33 @@ describe('SmtpEmailProvider', () => {
|
||||
new SmtpEmailProvider({host: 'smtp.example.com', port, username: 'user', password: 'pass', tlsMode});
|
||||
expect(createTransport).toHaveBeenCalledWith(expect.objectContaining(expected));
|
||||
});
|
||||
|
||||
const message = {
|
||||
to: '[email protected]',
|
||||
from: {email: '[email protected]', name: 'Example'},
|
||||
subject: 'Hi',
|
||||
text: 'Body',
|
||||
};
|
||||
|
||||
it('sends the configured headers verbatim', async () => {
|
||||
const provider = new SmtpEmailProvider({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
headers: {'X-Tracking': '{"enable":0}'},
|
||||
});
|
||||
await provider.sendEmail(message);
|
||||
const {sendMail} = createTransport.mock.results[0].value;
|
||||
expect(sendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({headers: {'X-Tracking': {prepared: true, value: '{"enable":0}'}}}),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends no extra headers when none are configured', async () => {
|
||||
const provider = new SmtpEmailProvider({host: 'smtp.example.com', port: 587, username: 'user', password: 'pass'});
|
||||
await provider.sendEmail(message);
|
||||
const {sendMail} = createTransport.mock.results[0].value;
|
||||
expect(sendMail.mock.calls[0][0]).not.toHaveProperty('headers');
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ interface SmtpEmailConfig {
|
||||
username: string;
|
||||
password: string;
|
||||
tlsMode?: SmtpTlsMode | null;
|
||||
headers?: Record<string, string>;
|
||||
connectionTimeoutMs?: number;
|
||||
greetingTimeoutMs?: number;
|
||||
socketTimeoutMs?: number;
|
||||
@@ -23,11 +24,17 @@ export class SmtpEmailProvider implements IEmailProvider {
|
||||
private readonly host: string;
|
||||
private readonly port: number;
|
||||
private readonly tlsMode: SmtpTlsMode;
|
||||
private readonly headers: Record<string, {prepared: true; value: string}> | undefined;
|
||||
|
||||
constructor(config: SmtpEmailConfig) {
|
||||
this.host = config.host;
|
||||
this.port = config.port;
|
||||
this.tlsMode = resolveSmtpTlsMode(config.tlsMode, config.port);
|
||||
this.headers = config.headers
|
||||
? Object.fromEntries(
|
||||
Object.entries(config.headers).map(([name, value]) => [name, {prepared: true as const, value}]),
|
||||
)
|
||||
: undefined;
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
@@ -55,6 +62,7 @@ export class SmtpEmailProvider implements IEmailProvider {
|
||||
to: message.to,
|
||||
from: `${message.from.name} <${message.from.email}>`,
|
||||
...(message.replyTo ? {replyTo: message.replyTo} : {}),
|
||||
...(this.headers ? {headers: this.headers} : {}),
|
||||
subject: message.subject,
|
||||
text: message.text,
|
||||
});
|
||||
|
||||
@@ -39,6 +39,18 @@ function resolveEmailAppBaseUrl(master: MasterConfig): string {
|
||||
}
|
||||
}
|
||||
|
||||
function toSmtpConfig(
|
||||
smtp: NonNullable<MasterConfig['integrations']['email']['smtp']>,
|
||||
): NonNullable<APIConfig['email']['smtp']> {
|
||||
return {
|
||||
host: smtp.host,
|
||||
port: smtp.port,
|
||||
username: smtp.username,
|
||||
password: smtp.password,
|
||||
tlsMode: smtp.tls_mode ?? smtpTlsModeFromSecure(smtp.secure),
|
||||
};
|
||||
}
|
||||
|
||||
function isBoolean(value: unknown): value is boolean {
|
||||
return typeof value === 'boolean';
|
||||
}
|
||||
@@ -291,14 +303,14 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
fromName: master.integrations.email.from_name,
|
||||
replyToEmail: master.integrations.email.reply_to_email,
|
||||
appBaseUrl: resolveEmailAppBaseUrl(master),
|
||||
smtp: master.integrations.email.smtp
|
||||
smtp: master.integrations.email.smtp ? toSmtpConfig(master.integrations.email.smtp) : undefined,
|
||||
fallback: master.integrations.email.fallback
|
||||
? {
|
||||
host: master.integrations.email.smtp.host,
|
||||
port: master.integrations.email.smtp.port,
|
||||
username: master.integrations.email.smtp.username,
|
||||
password: master.integrations.email.smtp.password,
|
||||
tlsMode:
|
||||
master.integrations.email.smtp.tls_mode ?? smtpTlsModeFromSecure(master.integrations.email.smtp.secure),
|
||||
domains: master.integrations.email.fallback.domains,
|
||||
smtp: {
|
||||
...toSmtpConfig(master.integrations.email.fallback.smtp),
|
||||
headers: master.integrations.email.fallback.smtp.headers,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
@@ -449,7 +461,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
inactivityDeletionThresholdDays: master.inactivity_deletion_threshold_days,
|
||||
reportRetention: {
|
||||
days: master.report_retention.days,
|
||||
resolvedDays: master.report_retention.resolved_days,
|
||||
dryRun: master.report_retention.dry_run,
|
||||
},
|
||||
push: {
|
||||
|
||||
@@ -245,7 +245,9 @@ import {
|
||||
type DSAReportTicketRow,
|
||||
GUILD_REPORT_SUBMISSION_BY_REPORTER_COLUMNS,
|
||||
type GuildReportSubmissionByReporterRow,
|
||||
IAR_REPORT_NOTE_COLUMNS,
|
||||
IAR_SUBMISSION_COLUMNS,
|
||||
type IARReportNoteRow,
|
||||
type IARSubmissionRow,
|
||||
MESSAGE_REPORT_SUBMISSION_BY_REPORTER_COLUMNS,
|
||||
type MessageReportSubmissionByReporterRow,
|
||||
@@ -941,6 +943,12 @@ export const IARSubmissions = defineTable<IARSubmissionRow, 'report_id'>({
|
||||
columns: IAR_SUBMISSION_COLUMNS,
|
||||
primaryKey: ['report_id'],
|
||||
});
|
||||
export const IARReportNotes = defineTable<IARReportNoteRow, 'report_id' | 'note_id', 'report_id'>({
|
||||
name: 'iar_report_notes',
|
||||
columns: IAR_REPORT_NOTE_COLUMNS,
|
||||
primaryKey: ['report_id', 'note_id'],
|
||||
partitionKey: ['report_id'],
|
||||
});
|
||||
export const MessageReportSubmissionsByReporter = defineTable<
|
||||
MessageReportSubmissionByReporterRow,
|
||||
'reporter_id' | 'channel_id' | 'message_id',
|
||||
|
||||
@@ -42,6 +42,7 @@ export const AdminAuditReadActions = {
|
||||
LIST_GUILD_THREADS: 'list_guild_threads',
|
||||
LIST_NETWORK_BLOCK_CANDIDATES: 'list_network_block_candidates',
|
||||
LIST_PENDING_REGISTRATIONS: 'list_pending_registrations',
|
||||
LIST_REPORT_NOTES: 'list_report_notes',
|
||||
LIST_REPORT_REASONS: 'list_report_reasons',
|
||||
LIST_USER_APPLICATIONS: 'list_user_applications',
|
||||
LIST_USER_CHANGE_LOG: 'list_user_change_log',
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
getEnforcementDecisionRepository,
|
||||
getGuildDiscoveryRepository,
|
||||
getKVAccountDeletionQueue,
|
||||
getReportRepository,
|
||||
} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApplicationRepository} from '@app/api/oauth/repositories/ApplicationRepository';
|
||||
import type {ReportService} from '@app/api/report/ReportService';
|
||||
@@ -120,6 +121,7 @@ export class AdminService {
|
||||
this.reportServiceAggregate = new AdminReportService({
|
||||
apiContext: this.apiContext,
|
||||
reportService: this.reportService,
|
||||
reportRepository: getReportRepository(),
|
||||
guildRepository: this.guildRepository,
|
||||
channelRepository: this.channelRepository,
|
||||
storageService: this.storageService,
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
ReportLegalHoldRequest,
|
||||
ReportLegalHoldResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminReportLegalHoldSchemas';
|
||||
import {
|
||||
ReportNoteListResponse,
|
||||
ReportNoteRequest,
|
||||
ReportNoteResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminReportNoteSchemas';
|
||||
import {
|
||||
AdminReportListResponse,
|
||||
AdminReportReasonsResponse,
|
||||
@@ -176,7 +181,7 @@ export function ReportAdminController(app: HonoApp) {
|
||||
operationId: 'get_admin_report',
|
||||
summary: 'Get report',
|
||||
description:
|
||||
'Retrieves detailed information about a specific report including content, reporter, reason, and the message context and profile snapshot captured when it was filed. Requires REPORT_VIEW permission.',
|
||||
'Retrieves detailed information about a specific report including content, reporter, reason, the message context and profile snapshot captured when it was filed, and its internal notes. Requires REPORT_VIEW permission.',
|
||||
responseSchema: ReportAdminResponseSchema,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -234,6 +239,64 @@ export function ReportAdminController(app: HonoApp) {
|
||||
);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/reports/:report_id/notes',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.REPORT_VIEW),
|
||||
Validator('param', ReportIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'list_admin_report_notes',
|
||||
summary: 'List report notes',
|
||||
description:
|
||||
'Lists the internal notes on a report, oldest first. Notes are staff only and are deleted with the report. Requires REPORT_VIEW permission.',
|
||||
responseSchema: ReportNoteListResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {report_id} = ctx.req.valid('param');
|
||||
const result = await ctx.get('adminService').reportServiceAggregate.listNotes(createReportID(report_id));
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'report',
|
||||
targetId: report_id,
|
||||
action: AdminAuditReadActions.LIST_REPORT_NOTES,
|
||||
metadata: {result_count: result.notes.length},
|
||||
});
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/reports/:report_id/notes',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.REPORT_RESOLVE),
|
||||
Validator('param', ReportIdParam),
|
||||
Validator('json', ReportNoteRequest),
|
||||
OpenAPI({
|
||||
operationId: 'annotate_admin_report',
|
||||
summary: 'Add a note to a report',
|
||||
description:
|
||||
'Appends an internal note to a report. Notes are staff only, never change and are deleted with the report. Creates audit log entry. Requires REPORT_RESOLVE permission.',
|
||||
responseSchema: ReportNoteResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {report_id} = ctx.req.valid('param');
|
||||
const reportId = createReportID(report_id);
|
||||
const note = await ctx
|
||||
.get('adminService')
|
||||
.reportServiceAggregate.addNote(reportId, ctx.req.valid('json').body, ctx.get('adminUserId'));
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'report',
|
||||
targetId: report_id,
|
||||
action: 'annotate_report',
|
||||
metadata: {report_id: reportId.toString(), note_id: note.note_id},
|
||||
});
|
||||
return ctx.json(note);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/reports/:report_id/legal-hold',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
@@ -294,7 +357,7 @@ export function ReportAdminController(app: HonoApp) {
|
||||
operationId: 'delete_admin_report',
|
||||
summary: 'Delete report',
|
||||
description:
|
||||
'Deletes a report now: its record with the captured message context and profile snapshot, its stored evidence copies, its search index entry, and the duplicate-report reservation it holds for the reporter. An evidence copy that another report also uses is kept. A report under a legal hold that has not ended is refused with REPORT_UNDER_LEGAL_HOLD. This cannot be undone. Creates an audit log entry. Requires REPORT_DELETE permission.',
|
||||
'Deletes a report now: its record with the captured message context and profile snapshot, its notes, its stored evidence copies, its search index entry, and the duplicate-report reservation it holds for the reporter. An evidence copy that another report also uses is kept. A report under a legal hold that has not ended is refused with REPORT_UNDER_LEGAL_HOLD. This cannot be undone. Creates an audit log entry. Requires REPORT_DELETE permission.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: 'adminApiKey',
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '@app/api/channel/services/message/MessageResponseDataService';
|
||||
import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope';
|
||||
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
|
||||
import type {IARReportNoteRow} from '@app/api/database/types/ReportTypes';
|
||||
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
|
||||
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
@@ -32,6 +33,7 @@ import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {describeReportFlowAnswers} from '@app/api/report/flows/ReportFlowRegistry';
|
||||
import {findReportReason, listReportReasons} from '@app/api/report/flows/ReportReasonCatalog';
|
||||
import type {IARMessageContext, IARSubmission} from '@app/api/report/ReportModels';
|
||||
import type {ReportRepository} from '@app/api/report/ReportRepository';
|
||||
import type {ReportService} from '@app/api/report/ReportService';
|
||||
import {getReportSearchService} from '@app/api/SearchFactory';
|
||||
import {isHiddenPartial} from '@app/api/user/ProfileVisibility';
|
||||
@@ -44,6 +46,7 @@ import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/F
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {UnknownReportError} from '@fluxer/errors/src/domains/moderation/UnknownReportError';
|
||||
import type {ReportSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {ReportNoteListResponse, ReportNoteResponse} from '@fluxer/schema/src/domains/admin/AdminReportNoteSchemas';
|
||||
import type {
|
||||
AdminReportReasonsResponse,
|
||||
ReportItemOutcome,
|
||||
@@ -58,6 +61,7 @@ import {seconds} from 'itty-time';
|
||||
interface AdminReportServiceDeps {
|
||||
apiContext: ApiContext;
|
||||
reportService: ReportService;
|
||||
reportRepository: ReportRepository;
|
||||
guildRepository: GuildRepository;
|
||||
channelRepository: ChannelRepository;
|
||||
storageService: IStorageService;
|
||||
@@ -136,6 +140,15 @@ function mapMissingAttachmentToResponse(attachment: MessageAttachment) {
|
||||
};
|
||||
}
|
||||
|
||||
function toReportNoteResponse(note: IARReportNoteRow): ReportNoteResponse {
|
||||
return {
|
||||
note_id: note.note_id.toString(),
|
||||
author_id: note.author_id.toString(),
|
||||
body: note.body,
|
||||
created_at: note.created_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function mapReportReasonFields(report: IARSubmission) {
|
||||
const reason = report.reason ? findReportReason(report.reason) : null;
|
||||
const flow =
|
||||
@@ -197,7 +210,30 @@ export class AdminReportService {
|
||||
const report = await reportService.getReport(reportId);
|
||||
const requestCache = createRequestCache();
|
||||
const reportLookupCache = createReportLookupCache();
|
||||
return this.mapReportToResponse(report, true, requestCache, acls, reportLookupCache);
|
||||
const [response, notes] = await Promise.all([
|
||||
this.mapReportToResponse(report, true, requestCache, acls, reportLookupCache),
|
||||
this.deps.reportRepository.listNotes(reportId),
|
||||
]);
|
||||
return {...response, notes: notes.map(toReportNoteResponse)};
|
||||
}
|
||||
|
||||
async listNotes(reportId: ReportID): Promise<ReportNoteListResponse> {
|
||||
await this.deps.reportService.getReport(reportId);
|
||||
const notes = await this.deps.reportRepository.listNotes(reportId);
|
||||
return {notes: notes.map(toReportNoteResponse)};
|
||||
}
|
||||
|
||||
async addNote(reportId: ReportID, body: string, authorId: UserID): Promise<ReportNoteResponse> {
|
||||
await this.deps.reportService.getReport(reportId);
|
||||
const note: IARReportNoteRow = {
|
||||
report_id: reportId,
|
||||
note_id: await this.deps.apiContext.services.snowflake.generate(),
|
||||
author_id: authorId,
|
||||
body,
|
||||
created_at: new Date(),
|
||||
};
|
||||
await this.deps.reportRepository.addNote(note);
|
||||
return toReportNoteResponse(note);
|
||||
}
|
||||
|
||||
async resolveReport(
|
||||
|
||||
@@ -33,6 +33,8 @@ interface ReporterNoticeResult {
|
||||
skipped: ReporterNoticeSkipReason | null;
|
||||
}
|
||||
|
||||
type ReporterToldOutcome = Exclude<ReportItemOutcome, 'warned'>;
|
||||
|
||||
function skippedNotice(reason: ReporterNoticeSkipReason): ReporterNoticeResult {
|
||||
return {dmSent: false, emailSent: false, dsaEmailSent: false, skipped: reason};
|
||||
}
|
||||
@@ -59,17 +61,18 @@ export class ReporterResolutionNotifier {
|
||||
outcome: ReportItemOutcome,
|
||||
): Promise<ReporterNoticeResult> {
|
||||
const comment = publicComment ?? '';
|
||||
const told = outcome === 'warned' ? 'action_taken' : outcome;
|
||||
if (report.reporterId) {
|
||||
return this.notifyAccountReporter(report, report.reporterId, comment, outcome);
|
||||
return this.notifyAccountReporter(report, report.reporterId, comment, told);
|
||||
}
|
||||
return this.notifyDsaReporter(report, comment, outcome);
|
||||
return this.notifyDsaReporter(report, comment, told);
|
||||
}
|
||||
|
||||
private async notifyAccountReporter(
|
||||
report: IARSubmission,
|
||||
reporterId: UserID,
|
||||
comment: string,
|
||||
outcome: ReportItemOutcome,
|
||||
outcome: ReporterToldOutcome,
|
||||
): Promise<ReporterNoticeResult> {
|
||||
const {users: userRepository, email: emailService} = this.deps.apiContext.services;
|
||||
const reportId = report.reportId.toString();
|
||||
@@ -115,7 +118,7 @@ export class ReporterResolutionNotifier {
|
||||
private async notifyDsaReporter(
|
||||
report: IARSubmission,
|
||||
comment: string,
|
||||
outcome: ReportItemOutcome,
|
||||
outcome: ReporterToldOutcome,
|
||||
): Promise<ReporterNoticeResult> {
|
||||
const email = report.reporterEmail;
|
||||
if (!email) {
|
||||
@@ -134,7 +137,7 @@ export class ReporterResolutionNotifier {
|
||||
reporter: User,
|
||||
reportId: string,
|
||||
comment: string,
|
||||
outcome: ReportItemOutcome,
|
||||
outcome: ReporterToldOutcome,
|
||||
): Promise<boolean> {
|
||||
const systemDm = this.deps.systemDm;
|
||||
if (!systemDm) {
|
||||
|
||||
@@ -234,6 +234,25 @@ describe('DELETE /admin/reports/:report_id', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('deletes the notes with the report and keeps the notes of other reports', async () => {
|
||||
const deleted = await seedMessageReport(newEvidence());
|
||||
const other = await seedMessageReport(newEvidence());
|
||||
for (const seeded of [deleted, other]) {
|
||||
await repository.addNote({
|
||||
report_id: seeded.reportId,
|
||||
note_id: nextId(),
|
||||
author_id: BigInt(admin.userId),
|
||||
body: 'Staff context',
|
||||
created_at: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
await deleteRequest(admin, deleted.reportId).expect(HTTP_STATUS.NO_CONTENT).execute();
|
||||
|
||||
expect(await repository.listNotes(deleted.reportId)).toEqual([]);
|
||||
expect(await repository.listNotes(other.reportId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('keeps an evidence copy another report still uses', async () => {
|
||||
const shared = newEvidence();
|
||||
const deleted = await seedMessageReport(shared);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createReportID, type ReportID} from '@app/api/BrandedTypes';
|
||||
import {IAR_SUBMISSION_COLUMNS, type IARSubmissionRow} from '@app/api/database/types/ReportTypes';
|
||||
import {EnforcementDecisionRepository} from '@app/api/enforcement/EnforcementDecisionRepository';
|
||||
import {ReportRepository} from '@app/api/report/ReportRepository';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {MockStorageService} from '@app/api/test/mocks/MockStorageService';
|
||||
@@ -47,6 +48,7 @@ describe('report legal hold', () => {
|
||||
...Object.fromEntries(IAR_SUBMISSION_COLUMNS.map((column) => [column, null])),
|
||||
report_id: sequence,
|
||||
reported_at: new Date(Date.now() - ageDays * ms('1 day')),
|
||||
resolved_at: new Date(Date.now() - ageDays * ms('1 day')),
|
||||
status: 1,
|
||||
report_type: 1,
|
||||
category: 'harassment',
|
||||
@@ -69,8 +71,13 @@ describe('report legal hold', () => {
|
||||
|
||||
async function runRetention() {
|
||||
return processReportRetention(
|
||||
{reportRepository: repository, storageService: new MockStorageService(), reportSearchService: null},
|
||||
{now: new Date(), dryRun: false, policy: {retentionDays: 365, resolvedRetentionDays: null}},
|
||||
{
|
||||
reportRepository: repository,
|
||||
storageService: new MockStorageService(),
|
||||
reportSearchService: null,
|
||||
decisionRepository: new EnforcementDecisionRepository(),
|
||||
},
|
||||
{now: new Date(), dryRun: false, policy: {retentionDays: 365}},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditLog} from '@app/api/admin/AdminRepository';
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createReportID, type ReportID} from '@app/api/BrandedTypes';
|
||||
import {IAR_SUBMISSION_COLUMNS, type IARSubmissionRow} from '@app/api/database/types/ReportTypes';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {ReportRepository} from '@app/api/report/ReportRepository';
|
||||
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 {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import type {ReportNoteListResponse, ReportNoteResponse} from '@fluxer/schema/src/domains/admin/AdminReportNoteSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface ValidationErrorResponse {
|
||||
code: string;
|
||||
errors?: Array<{path: string; message: string}>;
|
||||
}
|
||||
|
||||
const AUDIT_REASON = 'Ticket 314';
|
||||
let sequence = 1_496_000_000_000_000_000n;
|
||||
|
||||
describe('report notes', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let admin: TestAccount;
|
||||
let repository: ReportRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
AdminACLs.REPORT_VIEW,
|
||||
AdminACLs.REPORT_RESOLVE,
|
||||
]);
|
||||
repository = new ReportRepository();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function seedReport(): Promise<ReportID> {
|
||||
sequence += 1n;
|
||||
await repository.createReport({
|
||||
...Object.fromEntries(IAR_SUBMISSION_COLUMNS.map((column) => [column, null])),
|
||||
report_id: sequence,
|
||||
reported_at: new Date(),
|
||||
status: 0,
|
||||
report_type: 1,
|
||||
category: 'harassment',
|
||||
} as IARSubmissionRow);
|
||||
return createReportID(sequence);
|
||||
}
|
||||
|
||||
function addNote(account: TestAccount, reportId: ReportID | string, body: unknown) {
|
||||
return createBuilder<ReportNoteResponse & ValidationErrorResponse>(harness, account.token)
|
||||
.post(`/admin/reports/${reportId}/notes`)
|
||||
.header('X-Audit-Log-Reason', AUDIT_REASON)
|
||||
.body({body});
|
||||
}
|
||||
|
||||
function listNotes(account: TestAccount, reportId: ReportID | string) {
|
||||
return createBuilder<ReportNoteListResponse>(harness, account.token).get(`/admin/reports/${reportId}/notes`);
|
||||
}
|
||||
|
||||
async function auditEntries(action: string): Promise<Array<AdminAuditLog>> {
|
||||
const logs = await getAdminRepository().listAllAuditLogsPaginated(10000);
|
||||
return logs.filter((log) => log.action === action);
|
||||
}
|
||||
|
||||
test('adds notes, lists them oldest first and returns them with the report', async () => {
|
||||
const reportId = await seedReport();
|
||||
|
||||
const first = await addNote(admin, reportId, ' Reporter sent more context by email ')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const second = await addNote(admin, reportId, 'Waiting on the guild owner').expect(HTTP_STATUS.OK).execute();
|
||||
|
||||
expect(first).toEqual({
|
||||
note_id: expect.any(String),
|
||||
author_id: admin.userId,
|
||||
body: 'Reporter sent more context by email',
|
||||
created_at: expect.any(String),
|
||||
});
|
||||
expect(await listNotes(admin, reportId).expect(HTTP_STATUS.OK).execute()).toEqual({notes: [first, second]});
|
||||
const detail = await createBuilder<{notes?: Array<ReportNoteResponse>}>(harness, admin.token)
|
||||
.get(`/admin/reports/${reportId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(detail.notes).toEqual([first, second]);
|
||||
});
|
||||
|
||||
test('records the note in the audit log without its body', async () => {
|
||||
const reportId = await seedReport();
|
||||
|
||||
const note = await addNote(admin, reportId, 'Private staff context').expect(HTTP_STATUS.OK).execute();
|
||||
|
||||
const [entry, ...rest] = await auditEntries('annotate_report');
|
||||
expect(rest).toEqual([]);
|
||||
expect(entry?.adminUserId.toString()).toBe(admin.userId);
|
||||
expect(entry?.targetType).toBe('report');
|
||||
expect(entry?.targetId.toString()).toBe(reportId.toString());
|
||||
expect(entry?.auditLogReason).toBe(AUDIT_REASON);
|
||||
expect(Object.fromEntries(entry?.metadata ?? [])).toEqual({
|
||||
report_id: reportId.toString(),
|
||||
note_id: note.note_id,
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
['empty', ''],
|
||||
['blank', ' '],
|
||||
['too long', 'x'.repeat(2001)],
|
||||
['not a string', 42],
|
||||
])('refuses a %s body and stores nothing', async (_, body) => {
|
||||
const reportId = await seedReport();
|
||||
|
||||
const response = await addNote(admin, reportId, body)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
|
||||
expect(response.errors?.[0]?.path).toBe('body');
|
||||
expect(await repository.listNotes(reportId)).toEqual([]);
|
||||
expect(await auditEntries('annotate_report')).toEqual([]);
|
||||
});
|
||||
|
||||
test('accepts a body of 2000 characters', async () => {
|
||||
const reportId = await seedReport();
|
||||
|
||||
const note = await addNote(admin, reportId, 'x'.repeat(2000)).expect(HTTP_STATUS.OK).execute();
|
||||
|
||||
expect(note.body).toHaveLength(2000);
|
||||
});
|
||||
|
||||
test('an unknown report gives UNKNOWN_REPORT and stores nothing', async () => {
|
||||
sequence += 1n;
|
||||
const missing = createReportID(sequence);
|
||||
|
||||
await addNote(admin, missing, 'Note').expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_REPORT').execute();
|
||||
await listNotes(admin, missing).expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_REPORT').execute();
|
||||
|
||||
expect(await repository.listNotes(missing)).toEqual([]);
|
||||
expect(await auditEntries('annotate_report')).toEqual([]);
|
||||
});
|
||||
|
||||
test('adding a note needs REPORT_RESOLVE', async () => {
|
||||
const reportId = await seedReport();
|
||||
const viewer = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
AdminACLs.REPORT_VIEW,
|
||||
]);
|
||||
|
||||
await addNote(viewer, reportId, 'Note').expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL').execute();
|
||||
|
||||
expect(await repository.listNotes(reportId)).toEqual([]);
|
||||
expect(await auditEntries('annotate_report')).toEqual([]);
|
||||
});
|
||||
|
||||
test('listing notes needs REPORT_VIEW', async () => {
|
||||
const reportId = await seedReport();
|
||||
await addNote(admin, reportId, 'Note').expect(HTTP_STATUS.OK).execute();
|
||||
const resolver = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
AdminACLs.REPORT_RESOLVE,
|
||||
]);
|
||||
|
||||
await listNotes(resolver, reportId).expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL').execute();
|
||||
});
|
||||
});
|
||||
@@ -333,7 +333,7 @@ describe('Admin staff notifications', () => {
|
||||
expect(dm?.content).toContain('We found that what you reported broke our rules, and we took action.');
|
||||
expect(dm?.content).not.toMatch(/warn|restricted|suspend/i);
|
||||
const metadata = await auditMetadata('resolve_report', reportId);
|
||||
expect(metadata?.get('outcome')).toBe('action_taken');
|
||||
expect(metadata?.get('outcome')).toBe('warned');
|
||||
expect(metadata?.get('reporter_dm_sent')).toBe('true');
|
||||
});
|
||||
|
||||
@@ -347,6 +347,18 @@ describe('Admin staff notifications', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('a DSA notice resolved with a warning emails other action taken', async () => {
|
||||
const {email, reportId} = await fileDsaGuildNotice();
|
||||
await clearTestEmails(harness);
|
||||
await resolve(reportId, {outcome: 'warned'});
|
||||
const sent = await listTestEmails(harness, {recipient: email});
|
||||
expect(sent.map((entry) => [entry.type, entry.metadata.outcome])).toEqual([
|
||||
['dsa_report_resolved', 'action_taken'],
|
||||
]);
|
||||
const metadata = await auditMetadata('resolve_report', reportId);
|
||||
expect(metadata?.get('outcome')).toBe('warned');
|
||||
});
|
||||
|
||||
test('a DSA notice resolved with notify_reporter false gets the decision email without the comment', async () => {
|
||||
const {email, reportId} = await fileDsaGuildNotice();
|
||||
await clearTestEmails(harness);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {createReportID} from '@app/api/BrandedTypes';
|
||||
import {getReportRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {listReportReasons} from '@app/api/report/flows/ReportReasonCatalog';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {expect} from 'vitest';
|
||||
|
||||
interface FiledUserReport {
|
||||
reportId: string;
|
||||
@@ -321,6 +322,42 @@ export const ReportAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
},
|
||||
}),
|
||||
),
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/reports/:report_id/notes',
|
||||
async prepare(context) {
|
||||
const {reportId} = await fileUserReport(context);
|
||||
await createBuilder(context.harness, context.admin.token)
|
||||
.post(`/admin/reports/${reportId}/notes`)
|
||||
.body({body: 'Coverage note'})
|
||||
.execute();
|
||||
return {
|
||||
request: {path: `/admin/reports/${reportId}/notes`},
|
||||
expected: {
|
||||
action: 'list_report_notes',
|
||||
targetType: 'report',
|
||||
targetId: reportId,
|
||||
metadata: {result_count: '1'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/reports/:report_id/notes',
|
||||
async prepare(context) {
|
||||
const {reportId} = await fileUserReport(context);
|
||||
return {
|
||||
request: {path: `/admin/reports/${reportId}/notes`, body: {body: 'Coverage note'}},
|
||||
expected: {
|
||||
action: 'annotate_report',
|
||||
targetType: 'report',
|
||||
targetId: reportId,
|
||||
metadata: {report_id: reportId, note_id: expect.any(String)},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/reports/:report_id/legal-hold',
|
||||
|
||||
@@ -19,6 +19,14 @@ export interface TrustedCallerConfig {
|
||||
buckets: Array<string>;
|
||||
}
|
||||
|
||||
interface SmtpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
tlsMode: SmtpTlsMode | null;
|
||||
}
|
||||
|
||||
interface APICachePurgeTarget {
|
||||
endpoint: string;
|
||||
token: string;
|
||||
@@ -162,12 +170,10 @@ export interface APIConfig {
|
||||
fromName: string;
|
||||
replyToEmail: string;
|
||||
appBaseUrl: string;
|
||||
smtp?: {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
tlsMode: SmtpTlsMode | null;
|
||||
smtp?: SmtpConfig;
|
||||
fallback?: {
|
||||
domains: Array<string>;
|
||||
smtp: SmtpConfig & {headers?: Record<string, string>};
|
||||
};
|
||||
};
|
||||
blocklistFeeds: {
|
||||
@@ -308,7 +314,6 @@ export interface APIConfig {
|
||||
deletionGracePeriodHours: number;
|
||||
reportRetention: {
|
||||
days: number;
|
||||
resolvedDays: number | null;
|
||||
dryRun: boolean;
|
||||
};
|
||||
inactivityDeletionThresholdDays?: number;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DSAReportEmailVerifications,
|
||||
DSAReportTickets,
|
||||
GuildReportSubmissionsByReporter,
|
||||
IARReportNotes,
|
||||
IARSubmissions,
|
||||
MessageReportSubmissionsByReporter,
|
||||
UserReportSubmissionsByReporter,
|
||||
@@ -52,6 +53,7 @@ const IAR_MESSAGE_CONTEXT_FIELDS = {
|
||||
|
||||
const REPORT_TABLES = [
|
||||
IARSubmissions,
|
||||
IARReportNotes,
|
||||
MessageReportSubmissionsByReporter,
|
||||
UserReportSubmissionsByReporter,
|
||||
GuildReportSubmissionsByReporter,
|
||||
|
||||
@@ -89,6 +89,14 @@ export interface IARSubmissionRow {
|
||||
reporter_outcome?: string | null;
|
||||
}
|
||||
|
||||
export interface IARReportNoteRow {
|
||||
report_id: bigint;
|
||||
note_id: bigint;
|
||||
author_id: bigint;
|
||||
body: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface DSAReportEmailVerificationRow {
|
||||
email_lower: string;
|
||||
code_hash: string;
|
||||
@@ -186,6 +194,13 @@ export const IAR_SUBMISSION_COLUMNS = [
|
||||
'legal_hold_reason',
|
||||
'reporter_outcome',
|
||||
] as const satisfies ReadonlyArray<keyof IARSubmissionRow>;
|
||||
export const IAR_REPORT_NOTE_COLUMNS = [
|
||||
'report_id',
|
||||
'note_id',
|
||||
'author_id',
|
||||
'body',
|
||||
'created_at',
|
||||
] as const satisfies ReadonlyArray<keyof IARReportNoteRow>;
|
||||
export const DSA_REPORT_EMAIL_VERIFICATION_COLUMNS = [
|
||||
'email_lower',
|
||||
'code_hash',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {APIConfig} from '@app/api/config/APIConfig';
|
||||
import {createEmailProvider} from '@app/api/email/EmailProviderFactory';
|
||||
import {DomainRoutedEmailProvider} from '@pkgs/email/src/DomainRoutedEmailProvider';
|
||||
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
|
||||
import nodemailer, {type Transporter} from 'nodemailer';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const smtp = {host: 'smtp.example.com', port: 587, username: 'user', password: 'pass', tlsMode: null};
|
||||
|
||||
function emailConfig(overrides: Partial<APIConfig['email']> = {}): APIConfig['email'] {
|
||||
return {
|
||||
enabled: true,
|
||||
provider: 'smtp',
|
||||
fromEmail: '[email protected]',
|
||||
fromName: 'Example',
|
||||
replyToEmail: '',
|
||||
appBaseUrl: 'https://example.com',
|
||||
smtp,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('createEmailProvider', () => {
|
||||
it('returns the plain SMTP provider without a fallback', () => {
|
||||
expect(createEmailProvider(emailConfig())).toBeInstanceOf(SmtpEmailProvider);
|
||||
});
|
||||
|
||||
it('routes by domain when a fallback is set', () => {
|
||||
const provider = createEmailProvider(
|
||||
emailConfig({fallback: {domains: ['gmail.com'], smtp: {...smtp, host: 'smtp.sendgrid.net'}}}),
|
||||
);
|
||||
expect(provider).toBeInstanceOf(DomainRoutedEmailProvider);
|
||||
});
|
||||
|
||||
it('adds the fallback headers only to mail through the fallback', async () => {
|
||||
const sendMail = vi.fn();
|
||||
vi.spyOn(nodemailer, 'createTransport').mockReturnValue({sendMail} as unknown as Transporter);
|
||||
const headers = {'X-Tracking': '{"enable":0}'};
|
||||
const provider = createEmailProvider(emailConfig({fallback: {domains: ['gmail.com'], smtp: {...smtp, headers}}}));
|
||||
const from = {email: '[email protected]', name: 'Example'};
|
||||
await provider?.sendEmail({to: '[email protected]', from, subject: 'Hi', text: 'Body'});
|
||||
await provider?.sendEmail({to: '[email protected]', from, subject: 'Hi', text: 'Body'});
|
||||
expect(sendMail.mock.calls[0][0].headers).toEqual({'X-Tracking': {prepared: true, value: '{"enable":0}'}});
|
||||
expect(sendMail.mock.calls[1][0]).not.toHaveProperty('headers');
|
||||
});
|
||||
|
||||
it.each([{enabled: false}, {provider: 'none' as const}, {smtp: undefined}])(
|
||||
'returns no provider for %j even with a fallback',
|
||||
(overrides) => {
|
||||
const fallback = {domains: ['gmail.com'], smtp};
|
||||
expect(createEmailProvider(emailConfig({fallback, ...overrides}))).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {APIConfig} from '@app/api/config/APIConfig';
|
||||
import {DomainRoutedEmailProvider} from '@pkgs/email/src/DomainRoutedEmailProvider';
|
||||
import type {IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
|
||||
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
|
||||
|
||||
@@ -9,16 +10,14 @@ export function createEmailProvider(emailConfig: APIConfig['email']): IEmailProv
|
||||
return null;
|
||||
}
|
||||
switch (emailConfig.provider) {
|
||||
case 'smtp':
|
||||
return emailConfig.smtp
|
||||
? new SmtpEmailProvider({
|
||||
host: emailConfig.smtp.host,
|
||||
port: emailConfig.smtp.port,
|
||||
username: emailConfig.smtp.username,
|
||||
password: emailConfig.smtp.password,
|
||||
tlsMode: emailConfig.smtp.tlsMode,
|
||||
})
|
||||
: null;
|
||||
case 'smtp': {
|
||||
if (!emailConfig.smtp) return null;
|
||||
const primary = new SmtpEmailProvider(emailConfig.smtp);
|
||||
const {fallback} = emailConfig;
|
||||
return fallback
|
||||
? new DomainRoutedEmailProvider(primary, new SmtpEmailProvider(fallback.smtp), fallback.domains)
|
||||
: primary;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,16 @@ const LIST_NOTES_QUERY = EnforcementDecisionNotes.select({
|
||||
orderBy: {col: 'note_id', direction: 'ASC'},
|
||||
});
|
||||
|
||||
type DecisionReportLink = Pick<EnforcementDecisionRow, 'decision_id' | 'report_ids' | 'created_at'>;
|
||||
|
||||
function listReportLinksQuery(limit: number, paged: boolean) {
|
||||
return EnforcementDecisions.select({
|
||||
columns: ['decision_id', 'report_ids', 'created_at'],
|
||||
...(paged ? {where: EnforcementDecisions.where.tokenGt('decision_id', 'after')} : {}),
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
const REVIEW_QUEUE_BUCKET = 0;
|
||||
const LIST_REVIEW_QUEUE_QUERY = EnforcementReviewQueue.select({
|
||||
columns: ['decision_id'],
|
||||
@@ -162,6 +172,12 @@ export class EnforcementDecisionRepository {
|
||||
return this.getMany(keys.map((key) => key.decision_id));
|
||||
}
|
||||
|
||||
async listReportLinks(limit: number, after?: bigint): Promise<Array<DecisionReportLink>> {
|
||||
return fetchMany<DecisionReportLink>(
|
||||
listReportLinksQuery(limit, after !== undefined).bind(after !== undefined ? {after} : {}),
|
||||
);
|
||||
}
|
||||
|
||||
async end(decisionId: bigint, end: EnforcementDecisionEnd): Promise<void> {
|
||||
await upsertOne(EnforcementDecisions.patchByPk({decision_id: decisionId}, endPatch(end)));
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ function reportOutcome(
|
||||
(removeEvidence && evidence.some((message) => message.message_id === messageId));
|
||||
if (removed) return 'removed';
|
||||
}
|
||||
if (kind === 'warning') return 'action_taken';
|
||||
if (kind === 'warning') return 'warned';
|
||||
return RESTRICTING_KINDS.has(kind) ? 'restricted' : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {resolveSmtpTlsMode, type SmtpTlsMode, smtpTlsModeFromSecure} from '@fluxer/constants/src/SmtpConstants';
|
||||
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
|
||||
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
|
||||
import {normalizeThemeColor} from '@fluxer/instance_bootstrap/src/ThemeColor';
|
||||
import {
|
||||
InstanceConfigResponse,
|
||||
InstanceConfigUpdateRequest,
|
||||
@@ -414,14 +415,6 @@ function normalizeOptionalPublicString(value: string | null | undefined, fallbac
|
||||
return value === undefined ? fallback : normalizeOptionalString(value);
|
||||
}
|
||||
|
||||
function normalizeThemeColor(value: unknown): string | null {
|
||||
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(normalizeOptionalString(value) ?? '');
|
||||
if (match === null) return null;
|
||||
const digits = match[1].toLowerCase();
|
||||
const color = `#${digits.length === 3 ? digits.replace(/./g, '$&$&') : digits}`;
|
||||
return color === '#000000' ? null : color;
|
||||
}
|
||||
|
||||
function getDefaultPremiumProductName(): string {
|
||||
return Config.instance.selfHosted ? 'Premium' : 'Plutonium';
|
||||
}
|
||||
|
||||
@@ -79,13 +79,12 @@ async function storedObjectKeys(storageService: IStorageService, report: IARSubm
|
||||
return keys;
|
||||
}
|
||||
|
||||
export async function deleteReportWithEvidence(
|
||||
deps: ReportDeletionDeps,
|
||||
async function deleteReportEvidence(
|
||||
storageService: IStorageService,
|
||||
report: IARSubmission,
|
||||
ledger: ReportEvidenceLedger,
|
||||
options: {now: Date; dryRun: boolean},
|
||||
): Promise<ReportDeletionResult> {
|
||||
const {reportRepository, storageService, reportSearchService} = deps;
|
||||
const result: ReportDeletionResult = {objectsDeleted: 0, sharedObjectsKept: 0};
|
||||
for (const key of await storedObjectKeys(storageService, report)) {
|
||||
if (ledger.retainedKeys.has(key)) {
|
||||
@@ -105,6 +104,17 @@ export async function deleteReportWithEvidence(
|
||||
ledger.deletedKeys.add(key);
|
||||
result.objectsDeleted++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteReportWithEvidence(
|
||||
deps: ReportDeletionDeps,
|
||||
report: IARSubmission,
|
||||
ledger: ReportEvidenceLedger,
|
||||
options: {now: Date; dryRun: boolean},
|
||||
): Promise<ReportDeletionResult> {
|
||||
const {reportRepository, storageService, reportSearchService} = deps;
|
||||
const result = await deleteReportEvidence(storageService, report, ledger, options);
|
||||
if (!options.dryRun) {
|
||||
await reportSearchService?.deleteReport(report.reportId);
|
||||
await reportRepository.deleteReport(report.reportId);
|
||||
@@ -112,6 +122,25 @@ export async function deleteReportWithEvidence(
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function clearCopiedContextWithEvidence(
|
||||
deps: ReportDeletionDeps,
|
||||
report: IARSubmission,
|
||||
ledger: ReportEvidenceLedger,
|
||||
options: {now: Date; dryRun: boolean},
|
||||
): Promise<ReportDeletionResult> {
|
||||
const {reportRepository, storageService, reportSearchService} = deps;
|
||||
const result = await deleteReportEvidence(storageService, report, ledger, options);
|
||||
if (!options.dryRun && (await reportRepository.clearCopiedContext(report.reportId, report.reportType))) {
|
||||
await reportSearchService?.updateReport({
|
||||
...report,
|
||||
messageContext: null,
|
||||
reportedProfileSnapshot: null,
|
||||
additionalInfo: null,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loadDeletableReport(
|
||||
reportRepository: ReportRepository,
|
||||
reportId: ReportID,
|
||||
|
||||
@@ -10,12 +10,19 @@ import {
|
||||
createUserID,
|
||||
createWebhookID,
|
||||
} from '@app/api/BrandedTypes';
|
||||
import {executeConditional, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
|
||||
import {
|
||||
deleteOneOrMany,
|
||||
executeConditional,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
upsertOne,
|
||||
} from '@app/api/database/CassandraQueryExecution';
|
||||
import {Db} from '@app/api/database/CassandraTypes';
|
||||
import type {
|
||||
DSAReportEmailVerificationRow,
|
||||
DSAReportTicketRow,
|
||||
GuildReportSubmissionByReporterRow,
|
||||
IARReportNoteRow,
|
||||
MessageReportSubmissionByReporterRow,
|
||||
UserReportSubmissionByReporterRow,
|
||||
} from '@app/api/database/types/ReportTypes';
|
||||
@@ -32,6 +39,7 @@ import {
|
||||
DSAReportEmailVerifications,
|
||||
DSAReportTickets,
|
||||
GuildReportSubmissionsByReporter,
|
||||
IARReportNotes,
|
||||
IARSubmissions,
|
||||
MessageReportSubmissionsByReporter,
|
||||
UserReportSubmissionsByReporter,
|
||||
@@ -45,6 +53,10 @@ const GET_REPORT_QUERY = IARSubmissions.select({
|
||||
where: IARSubmissions.where.eq('report_id'),
|
||||
limit: 1,
|
||||
});
|
||||
const LIST_NOTES_QUERY = IARReportNotes.select({
|
||||
where: IARReportNotes.where.eq('report_id'),
|
||||
orderBy: {col: 'note_id', direction: 'ASC'},
|
||||
});
|
||||
const createFetchAllReportsPaginatedQuery = (limit: number) =>
|
||||
IARSubmissions.select({
|
||||
where: IARSubmissions.where.tokenGt('report_id', 'last_report_id'),
|
||||
@@ -186,6 +198,20 @@ export class ReportRepository {
|
||||
return {...report, legalHoldUntil, legalHoldReason};
|
||||
}
|
||||
|
||||
async clearCopiedContext(reportId: ReportID, reportType: number): Promise<boolean> {
|
||||
return executeConditional(
|
||||
IARSubmissions.conditionalPatchByPk(
|
||||
{report_id: reportId},
|
||||
{
|
||||
message_context: Db.set(null),
|
||||
reported_profile_snapshot: Db.set(null),
|
||||
additional_info: Db.set(null),
|
||||
},
|
||||
{report_type: reportType},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async clearReporterEmail(reportId: ReportID, reporterId: UserID): Promise<boolean> {
|
||||
return executeConditional(
|
||||
IARSubmissions.conditionalPatchByPk(
|
||||
@@ -202,11 +228,20 @@ export class ReportRepository {
|
||||
return;
|
||||
}
|
||||
await this.releaseReporterReservation(report);
|
||||
await deleteOneOrMany(IARReportNotes.deletePartition({report_id: reportId}));
|
||||
await executeConditional(
|
||||
IARSubmissions.conditionalDeleteByPk({report_id: reportId}, {report_type: report.reportType}),
|
||||
);
|
||||
}
|
||||
|
||||
async addNote(note: IARReportNoteRow): Promise<void> {
|
||||
await upsertOne(IARReportNotes.insert(note));
|
||||
}
|
||||
|
||||
async listNotes(reportId: ReportID): Promise<Array<IARReportNoteRow>> {
|
||||
return fetchMany<IARReportNoteRow>(LIST_NOTES_QUERY.bind({report_id: reportId}));
|
||||
}
|
||||
|
||||
private async releaseReporterReservation(report: IARSubmission): Promise<void> {
|
||||
const {reporterId, reportId, reportedAt} = report;
|
||||
if (!reporterId) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {clearTestEmails, createUniqueEmail, findLastTestEmail, listTestEmails} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {ReportRepository} from '@app/api/report/ReportRepository';
|
||||
import {DSAReportEmailVerifications, DSAReportTickets, IARSubmissions} from '@app/api/Tables';
|
||||
import {DSAReportEmailVerifications, DSAReportTickets, IARReportNotes, IARSubmissions} from '@app/api/Tables';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
@@ -29,6 +29,11 @@ describe('report table TTLs', () => {
|
||||
expect(IARSubmissions.defaultTtlSeconds).toBeUndefined();
|
||||
expect(schemaOptions(IARSubmissions.name)).not.toContain('default_time_to_live');
|
||||
});
|
||||
|
||||
it('keeps report notes without a table TTL, so they go only with the report', () => {
|
||||
expect(IARReportNotes.defaultTtlSeconds).toBeUndefined();
|
||||
expect(schemaOptions(IARReportNotes.name)).not.toContain('default_time_to_live');
|
||||
});
|
||||
});
|
||||
|
||||
describe('report table TTLs cover the service windows', () => {
|
||||
|
||||
@@ -2,23 +2,36 @@
|
||||
|
||||
import type {ReportID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {EnforcementDecisionRepository} from '@app/api/enforcement/EnforcementDecisionRepository';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {
|
||||
clearCopiedContextWithEvidence,
|
||||
deleteReportWithEvidence,
|
||||
forEachStoredReport,
|
||||
isReportUnderLegalHold,
|
||||
type ReportDeletionDeps,
|
||||
type ReportEvidenceLedger,
|
||||
referencedObjectKeys,
|
||||
} from '@app/api/report/ReportDeletion';
|
||||
import {type IARSubmission, ReportStatus} from '@app/api/report/ReportModels';
|
||||
import {getReportSearchService} from '@app/api/SearchFactory';
|
||||
import {EnforcementDecisionEvidence} from '@app/api/Tables';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
export const REPORT_COMPLAINT_WINDOW_DAYS = 184;
|
||||
export const CHILD_SAFETY_REPORT_RETENTION_DAYS = 365;
|
||||
const CHILD_SAFETY_CATEGORY = 'child_safety';
|
||||
const DECISION_EVIDENCE_RETENTION_MS = EnforcementDecisionEvidence.defaultTtlSeconds! * 1000;
|
||||
const DEFAULT_SCAN_PAGE_SIZE = 500;
|
||||
|
||||
export interface ReportRetentionPolicy {
|
||||
retentionDays: number;
|
||||
resolvedRetentionDays: number | null;
|
||||
}
|
||||
|
||||
export interface ReportRetentionDeps extends ReportDeletionDeps {
|
||||
decisionRepository: Pick<EnforcementDecisionRepository, 'listReportLinks'>;
|
||||
}
|
||||
|
||||
export interface ReportRetentionOptions {
|
||||
@@ -31,65 +44,136 @@ export interface ReportRetentionOptions {
|
||||
export interface ReportRetentionSummary {
|
||||
dryRun: boolean;
|
||||
scanned: number;
|
||||
pending: number;
|
||||
expired: number;
|
||||
contextExpired: number;
|
||||
held: number;
|
||||
deleted: number;
|
||||
contextCleared: number;
|
||||
objectsDeleted: number;
|
||||
sharedObjectsKept: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
type ReportRetentionState = 'expired' | 'held' | 'retained';
|
||||
|
||||
function reportExpiresAt(report: IARSubmission, policy: ReportRetentionPolicy): number {
|
||||
const reportedAt = report.reportedAt instanceof Date ? report.reportedAt.getTime() : Number.NaN;
|
||||
const byAge = reportedAt + policy.retentionDays * ms('1 day');
|
||||
if (policy.resolvedRetentionDays === null || report.status !== ReportStatus.RESOLVED) {
|
||||
return byAge;
|
||||
}
|
||||
const resolvedAt = report.resolvedAt instanceof Date ? report.resolvedAt.getTime() : Number.NaN;
|
||||
if (Number.isNaN(resolvedAt)) {
|
||||
return byAge;
|
||||
}
|
||||
return Math.min(byAge, resolvedAt + policy.resolvedRetentionDays * ms('1 day'));
|
||||
export interface ReportRetentionDates {
|
||||
deleteAt: number | null;
|
||||
contextExpiresAt: number | null;
|
||||
}
|
||||
|
||||
function reportRetentionState(report: IARSubmission, now: Date, policy: ReportRetentionPolicy): ReportRetentionState {
|
||||
const expiresAt = reportExpiresAt(report, policy);
|
||||
if (Number.isNaN(expiresAt) || expiresAt > now.getTime()) {
|
||||
type ReportRetentionState = 'expired' | 'context_expired' | 'held' | 'retained';
|
||||
|
||||
const KEEP_UNTIL_RESOLVED: ReportRetentionDates = {deleteAt: null, contextExpiresAt: null};
|
||||
|
||||
export function reportRetentionDates(
|
||||
report: IARSubmission,
|
||||
policy: ReportRetentionPolicy,
|
||||
decidedAt: Date | null,
|
||||
): ReportRetentionDates {
|
||||
if (report.status !== ReportStatus.RESOLVED) {
|
||||
return KEEP_UNTIL_RESOLVED;
|
||||
}
|
||||
const resolvedAt = (report.resolvedAt ?? report.reportedAt).getTime();
|
||||
if (Number.isNaN(resolvedAt)) {
|
||||
return KEEP_UNTIL_RESOLVED;
|
||||
}
|
||||
const childSafety = report.category === CHILD_SAFETY_CATEGORY;
|
||||
const ends = [resolvedAt + policy.retentionDays * ms('1 day')];
|
||||
if (childSafety) {
|
||||
ends.push(resolvedAt + CHILD_SAFETY_REPORT_RETENTION_DAYS * ms('1 day'));
|
||||
}
|
||||
if (decidedAt) {
|
||||
ends.push(decidedAt.getTime() + DECISION_EVIDENCE_RETENTION_MS);
|
||||
}
|
||||
const dropsContextEarly = report.reporterOutcome === 'no_violation' && !childSafety && !decidedAt;
|
||||
return {
|
||||
deleteAt: Math.max(...ends),
|
||||
contextExpiresAt: dropsContextEarly ? resolvedAt + REPORT_COMPLAINT_WINDOW_DAYS * ms('1 day') : null,
|
||||
};
|
||||
}
|
||||
|
||||
function hasCopiedContext(report: IARSubmission): boolean {
|
||||
return (
|
||||
(report.messageContext?.length ?? 0) > 0 ||
|
||||
report.reportedProfileSnapshot !== null ||
|
||||
report.additionalInfo !== null
|
||||
);
|
||||
}
|
||||
|
||||
function reportRetentionState(
|
||||
report: IARSubmission,
|
||||
now: Date,
|
||||
policy: ReportRetentionPolicy,
|
||||
decidedAt: Date | null,
|
||||
): ReportRetentionState {
|
||||
const {deleteAt, contextExpiresAt} = reportRetentionDates(report, policy, decidedAt);
|
||||
const due =
|
||||
deleteAt !== null && deleteAt <= now.getTime()
|
||||
? 'expired'
|
||||
: contextExpiresAt !== null && contextExpiresAt <= now.getTime() && hasCopiedContext(report)
|
||||
? 'context_expired'
|
||||
: null;
|
||||
if (due === null) {
|
||||
return 'retained';
|
||||
}
|
||||
if (isReportUnderLegalHold(report, now)) {
|
||||
return 'held';
|
||||
return isReportUnderLegalHold(report, now) ? 'held' : due;
|
||||
}
|
||||
|
||||
async function latestDecisionByReport(
|
||||
decisionRepository: ReportRetentionDeps['decisionRepository'],
|
||||
pageSize: number,
|
||||
): Promise<Map<bigint, Date>> {
|
||||
const decidedAt = new Map<bigint, Date>();
|
||||
let cursor: bigint | undefined;
|
||||
while (true) {
|
||||
const page = await decisionRepository.listReportLinks(pageSize, cursor);
|
||||
for (const decision of page) {
|
||||
for (const reportId of decision.report_ids ?? []) {
|
||||
const known = decidedAt.get(reportId);
|
||||
if (!known || known.getTime() < decision.created_at.getTime()) {
|
||||
decidedAt.set(reportId, decision.created_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (page.length < pageSize) return decidedAt;
|
||||
cursor = page[page.length - 1]!.decision_id;
|
||||
}
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
export async function processReportRetention(
|
||||
deps: ReportDeletionDeps,
|
||||
deps: ReportRetentionDeps,
|
||||
options: ReportRetentionOptions,
|
||||
): Promise<ReportRetentionSummary> {
|
||||
const {reportRepository} = deps;
|
||||
const {now, dryRun, policy} = options;
|
||||
const pageSize = options.pageSize ?? DEFAULT_SCAN_PAGE_SIZE;
|
||||
const summary: ReportRetentionSummary = {
|
||||
dryRun,
|
||||
scanned: 0,
|
||||
pending: 0,
|
||||
expired: 0,
|
||||
contextExpired: 0,
|
||||
held: 0,
|
||||
deleted: 0,
|
||||
contextCleared: 0,
|
||||
objectsDeleted: 0,
|
||||
sharedObjectsKept: 0,
|
||||
failed: 0,
|
||||
};
|
||||
const decidedAt = await latestDecisionByReport(deps.decisionRepository, pageSize);
|
||||
const stateOf = (report: IARSubmission) =>
|
||||
reportRetentionState(report, now, policy, decidedAt.get(report.reportId) ?? null);
|
||||
const retainedKeys = new Set<string>();
|
||||
const expiredIds: Array<ReportID> = [];
|
||||
const due: Array<{reportId: ReportID; state: 'expired' | 'context_expired'}> = [];
|
||||
await forEachStoredReport(
|
||||
reportRepository,
|
||||
(report) => {
|
||||
summary.scanned++;
|
||||
const state = reportRetentionState(report, now, policy);
|
||||
if (state === 'expired') {
|
||||
expiredIds.push(report.reportId);
|
||||
if (report.status !== ReportStatus.RESOLVED) {
|
||||
summary.pending++;
|
||||
}
|
||||
const state = stateOf(report);
|
||||
if (state === 'expired' || state === 'context_expired') {
|
||||
due.push({reportId: report.reportId, state});
|
||||
return;
|
||||
}
|
||||
if (state === 'held') {
|
||||
@@ -99,35 +183,37 @@ export async function processReportRetention(
|
||||
retainedKeys.add(key);
|
||||
}
|
||||
},
|
||||
options.pageSize,
|
||||
pageSize,
|
||||
);
|
||||
summary.expired = expiredIds.length;
|
||||
const ledger = {retainedKeys, deletedKeys: new Set<string>()};
|
||||
for (const reportId of expiredIds) {
|
||||
summary.expired = due.filter((entry) => entry.state === 'expired').length;
|
||||
summary.contextExpired = due.length - summary.expired;
|
||||
const ledger: ReportEvidenceLedger = {retainedKeys, deletedKeys: new Set<string>()};
|
||||
for (const {reportId, state} of due) {
|
||||
try {
|
||||
const report = await reportRepository.getReport(reportId);
|
||||
if (!report) continue;
|
||||
if (reportRetentionState(report, now, policy) !== 'expired') {
|
||||
if (stateOf(report) !== state) {
|
||||
for (const key of referencedObjectKeys(report)) {
|
||||
retainedKeys.add(key);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const result = await deleteReportWithEvidence(deps, report, ledger, {now, dryRun});
|
||||
const apply = state === 'expired' ? deleteReportWithEvidence : clearCopiedContextWithEvidence;
|
||||
const result = await apply(deps, report, ledger, {now, dryRun});
|
||||
summary.objectsDeleted += result.objectsDeleted;
|
||||
summary.sharedObjectsKept += result.sharedObjectsKept;
|
||||
summary.deleted++;
|
||||
if (state === 'expired') {
|
||||
summary.deleted++;
|
||||
} else {
|
||||
summary.contextCleared++;
|
||||
}
|
||||
} catch (error) {
|
||||
summary.failed++;
|
||||
Logger.error({error, reportId: reportId.toString()}, 'Failed to delete an expired report');
|
||||
Logger.error({error, reportId: reportId.toString(), state}, 'Failed to apply report retention');
|
||||
}
|
||||
}
|
||||
Logger.info(
|
||||
{
|
||||
...summary,
|
||||
retentionDays: policy.retentionDays,
|
||||
resolvedRetentionDays: policy.resolvedRetentionDays,
|
||||
},
|
||||
{...summary, retentionDays: policy.retentionDays, complaintWindowDays: REPORT_COMPLAINT_WINDOW_DAYS},
|
||||
'Processed report retention',
|
||||
);
|
||||
return summary;
|
||||
@@ -135,10 +221,15 @@ export async function processReportRetention(
|
||||
|
||||
const expireReportSnapshots: WorkerTaskHandler = async () => {
|
||||
const {reportRepository, storageService} = getWorkerDependencies();
|
||||
const {days, resolvedDays, dryRun} = Config.reportRetention;
|
||||
const {days, dryRun} = Config.reportRetention;
|
||||
await processReportRetention(
|
||||
{reportRepository, storageService, reportSearchService: getReportSearchService()},
|
||||
{now: new Date(), dryRun, policy: {retentionDays: days, resolvedRetentionDays: resolvedDays}},
|
||||
{
|
||||
reportRepository,
|
||||
storageService,
|
||||
reportSearchService: getReportSearchService(),
|
||||
decisionRepository: new EnforcementDecisionRepository(),
|
||||
},
|
||||
{now: new Date(), dryRun, policy: {retentionDays: days}},
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ export function createWorkerEnforcementServices(deps: WorkerDependencies): Worke
|
||||
adminReportService: new AdminReportService({
|
||||
apiContext,
|
||||
reportService,
|
||||
reportRepository: deps.reportRepository,
|
||||
guildRepository: deps.guildRepository,
|
||||
channelRepository: deps.channelRepository,
|
||||
storageService: deps.storageService,
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import {createAttachmentID, createChannelID, createReportID, createUserID, type ReportID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {makeAttachmentCdnKey} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {ENFORCEMENT_DECISION_COLUMNS, type EnforcementDecisionRow} from '@app/api/database/types/EnforcementTypes';
|
||||
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
|
||||
import {
|
||||
IAR_SUBMISSION_COLUMNS,
|
||||
type IARMessageContextRow,
|
||||
type IARSubmissionRow,
|
||||
} from '@app/api/database/types/ReportTypes';
|
||||
import type {ReportDeletionDeps} from '@app/api/report/ReportDeletion';
|
||||
import {EnforcementDecisionRepository} from '@app/api/enforcement/EnforcementDecisionRepository';
|
||||
import type {IARSubmission} from '@app/api/report/ReportModels';
|
||||
import {ReportRepository} from '@app/api/report/ReportRepository';
|
||||
import type {IReportSearchService} from '@app/api/search/IReportSearchService';
|
||||
@@ -17,11 +18,17 @@ import {MockStorageService} from '@app/api/test/mocks/MockStorageService';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import {InMemorySearchProvider} from '@app/api/test/search/InMemorySearchProvider';
|
||||
import expireReportSnapshots, {
|
||||
CHILD_SAFETY_REPORT_RETENTION_DAYS,
|
||||
processReportRetention,
|
||||
REPORT_COMPLAINT_WINDOW_DAYS,
|
||||
type ReportRetentionDeps,
|
||||
type ReportRetentionOptions,
|
||||
type ReportRetentionPolicy,
|
||||
reportRetentionDates,
|
||||
} from '@app/api/worker/tasks/ExpireReportSnapshots';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
import type {ReportItemOutcome} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {serializeReportProfileSnapshot} from '@fluxer/schema/src/domains/report/ReportProfileSnapshotSchemas';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {ms} from 'itty-time';
|
||||
@@ -31,7 +38,7 @@ const NOW = new Date('2027-06-01T12:00:00.000Z');
|
||||
const DAY_MS = ms('1 day');
|
||||
const REPORTS_BUCKET = Config.s3.buckets.reports;
|
||||
const WORKER_HELPERS = {logger: new NoopLogger()} as unknown as WorkerTaskHelpers;
|
||||
const DEFAULT_POLICY: ReportRetentionPolicy = {retentionDays: 365, resolvedRetentionDays: null};
|
||||
const DEFAULT_POLICY: ReportRetentionPolicy = {retentionDays: 365};
|
||||
let sequence = 1_480_000_000_000_000_000n;
|
||||
|
||||
function nextId(): bigint {
|
||||
@@ -93,12 +100,16 @@ interface SharedAttachment {
|
||||
}
|
||||
|
||||
interface SeedOptions {
|
||||
ageDays: number;
|
||||
ageDays?: number;
|
||||
resolvedDaysAgo?: number;
|
||||
attachment?: SharedAttachment;
|
||||
profile?: boolean;
|
||||
legalHoldUntil?: Date | null;
|
||||
status?: number;
|
||||
resolvedDaysAgo?: number;
|
||||
category?: string;
|
||||
outcome?: ReportItemOutcome | null;
|
||||
additionalInfo?: string | null;
|
||||
reporterId?: bigint | null;
|
||||
snapshotJson?: string;
|
||||
}
|
||||
|
||||
@@ -116,20 +127,96 @@ function attachmentKey(attachment: SharedAttachment): string {
|
||||
return makeAttachmentCdnKey(createChannelID(attachment.channelId), attachment.attachmentId, attachment.filename);
|
||||
}
|
||||
|
||||
function storedReport(overrides: Partial<IARSubmission>): IARSubmission {
|
||||
return {
|
||||
reportId: createReportID(nextId()),
|
||||
reportedAt: daysAgo(500),
|
||||
status: 1,
|
||||
resolvedAt: daysAgo(10),
|
||||
category: 'harassment',
|
||||
reporterOutcome: 'removed',
|
||||
...overrides,
|
||||
} as IARSubmission;
|
||||
}
|
||||
|
||||
describe('report retention dates', () => {
|
||||
const day = DAY_MS;
|
||||
|
||||
it('keeps a pending report until it is resolved', () => {
|
||||
expect(reportRetentionDates(storedReport({status: 0, resolvedAt: null}), DEFAULT_POLICY, null)).toEqual({
|
||||
deleteAt: null,
|
||||
contextExpiresAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes an actioned report one year after it was resolved and keeps its context until then', () => {
|
||||
const report = storedReport({});
|
||||
expect(reportRetentionDates(report, DEFAULT_POLICY, null)).toEqual({
|
||||
deleteAt: report.resolvedAt!.getTime() + 365 * day,
|
||||
contextExpiresAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops the context of a no violation report once the complaint window has passed', () => {
|
||||
const report = storedReport({reporterOutcome: 'no_violation'});
|
||||
const dates = reportRetentionDates(report, DEFAULT_POLICY, null);
|
||||
expect(dates.contextExpiresAt).toBe(report.resolvedAt!.getTime() + REPORT_COMPLAINT_WINDOW_DAYS * day);
|
||||
expect(REPORT_COMPLAINT_WINDOW_DAYS).toBeGreaterThanOrEqual(183);
|
||||
expect(dates.deleteAt).toBe(report.resolvedAt!.getTime() + 365 * day);
|
||||
});
|
||||
|
||||
it('keeps a child safety report and its context for at least a year whatever the configured period', () => {
|
||||
const report = storedReport({category: 'child_safety', reporterOutcome: 'no_violation'});
|
||||
expect(reportRetentionDates(report, {retentionDays: 30}, null)).toEqual({
|
||||
deleteAt: report.resolvedAt!.getTime() + CHILD_SAFETY_REPORT_RETENTION_DAYS * day,
|
||||
contextExpiresAt: null,
|
||||
});
|
||||
expect(CHILD_SAFETY_REPORT_RETENTION_DAYS).toBeGreaterThanOrEqual(365);
|
||||
});
|
||||
|
||||
it('keeps a report linked to a decision as long as the decision evidence and keeps its context', () => {
|
||||
const report = storedReport({resolvedAt: daysAgo(300), reporterOutcome: 'no_violation'});
|
||||
const decidedAt = daysAgo(100);
|
||||
expect(reportRetentionDates(report, DEFAULT_POLICY, decidedAt)).toEqual({
|
||||
deleteAt: decidedAt.getTime() + 365 * day,
|
||||
contextExpiresAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the longer period when the configured one is longer than a floor', () => {
|
||||
const report = storedReport({category: 'child_safety'});
|
||||
expect(reportRetentionDates(report, {retentionDays: 730}, null).deleteAt).toBe(
|
||||
report.resolvedAt!.getTime() + 730 * day,
|
||||
);
|
||||
});
|
||||
|
||||
it('counts from the filing time for a resolved report without a resolution time', () => {
|
||||
const report = storedReport({resolvedAt: null});
|
||||
expect(reportRetentionDates(report, DEFAULT_POLICY, null).deleteAt).toBe(report.reportedAt.getTime() + 365 * day);
|
||||
});
|
||||
});
|
||||
|
||||
describe('report retention', () => {
|
||||
let repository: ReportRepository;
|
||||
let decisions: EnforcementDecisionRepository;
|
||||
let storage: MockStorageService;
|
||||
let search: IReportSearchService;
|
||||
let deps: ReportDeletionDeps;
|
||||
let deps: ReportRetentionDeps;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new ReportRepository();
|
||||
decisions = new EnforcementDecisionRepository();
|
||||
storage = new MockStorageService();
|
||||
search = new InMemorySearchProvider().getReportSearchService();
|
||||
deps = {reportRepository: repository, storageService: storage, reportSearchService: search};
|
||||
deps = {
|
||||
reportRepository: repository,
|
||||
storageService: storage,
|
||||
reportSearchService: search,
|
||||
decisionRepository: decisions,
|
||||
};
|
||||
});
|
||||
|
||||
async function seed(options: SeedOptions): Promise<SeededReport> {
|
||||
async function seed(options: SeedOptions = {}): Promise<SeededReport> {
|
||||
const id = nextId();
|
||||
const reportId = createReportID(id);
|
||||
let storedAttachmentKey: string | null = null;
|
||||
@@ -161,14 +248,18 @@ describe('report retention', () => {
|
||||
guild: null,
|
||||
});
|
||||
}
|
||||
const resolved = options.resolvedDaysAgo !== undefined;
|
||||
const row = {
|
||||
...Object.fromEntries(IAR_SUBMISSION_COLUMNS.map((column) => [column, null])),
|
||||
report_id: id,
|
||||
reported_at: daysAgo(options.ageDays),
|
||||
status: options.status ?? (options.resolvedDaysAgo === undefined ? 0 : 1),
|
||||
resolved_at: options.resolvedDaysAgo === undefined ? null : daysAgo(options.resolvedDaysAgo),
|
||||
reporter_id: options.reporterId ?? null,
|
||||
reported_at: daysAgo(options.ageDays ?? (options.resolvedDaysAgo ?? 0) + 5),
|
||||
status: options.status ?? (resolved ? 1 : 0),
|
||||
resolved_at: resolved ? daysAgo(options.resolvedDaysAgo!) : null,
|
||||
reporter_outcome: options.outcome === undefined ? (resolved ? 'removed' : null) : options.outcome,
|
||||
report_type: 0,
|
||||
category: 'other',
|
||||
category: options.category ?? 'other',
|
||||
additional_info: options.additionalInfo ?? null,
|
||||
reported_channel_id: options.attachment?.channelId ?? null,
|
||||
message_context: messageContext,
|
||||
reported_profile_snapshot: snapshot,
|
||||
@@ -180,6 +271,30 @@ describe('report retention', () => {
|
||||
return {reportId, attachmentKey: storedAttachmentKey, profileKey};
|
||||
}
|
||||
|
||||
async function linkDecision(reportIds: Array<ReportID>, createdAt: Date): Promise<void> {
|
||||
const decisionId = nextId();
|
||||
const decision = {
|
||||
...Object.fromEntries(ENFORCEMENT_DECISION_COLUMNS.map((column) => [column, null])),
|
||||
decision_id: decisionId,
|
||||
subject_type: 'user',
|
||||
subject_id: nextId(),
|
||||
kind: 'warning',
|
||||
reason_code: DeletionReasons.HARASSMENT_OR_BULLYING,
|
||||
source: 'report',
|
||||
automation: 'none',
|
||||
report_ids: new Set<bigint>(reportIds),
|
||||
decided_by: nextId(),
|
||||
created_at: createdAt,
|
||||
status: 'active',
|
||||
notice_status: 'sent',
|
||||
} as EnforcementDecisionRow;
|
||||
await decisions.create({
|
||||
decision,
|
||||
evidence: null,
|
||||
firstNote: {decision_id: decisionId, note_id: nextId(), author_id: nextId(), body: 'note', created_at: createdAt},
|
||||
});
|
||||
}
|
||||
|
||||
async function indexedIds(): Promise<Array<string>> {
|
||||
const result = await search.searchReports('', {}, {limit: 1000});
|
||||
return result.hits.map((hit) => hit.id).sort();
|
||||
@@ -199,11 +314,27 @@ describe('report retention', () => {
|
||||
if (seeded.profileKey) expect(storage.hasObject(REPORTS_BUCKET, seeded.profileKey)).toBe(false);
|
||||
}
|
||||
|
||||
it('deletes a report 365 days after it was filed with its objects, search document and row', async () => {
|
||||
const expired = await seed({ageDays: 365, attachment: newAttachment(), profile: true});
|
||||
const kept = await seed({ageDays: 364, attachment: newAttachment(), profile: true});
|
||||
async function expectContextCleared(seeded: SeededReport): Promise<void> {
|
||||
const report = await repository.getReport(seeded.reportId);
|
||||
expect(report).not.toBeNull();
|
||||
expect(report?.messageContext).toBeNull();
|
||||
expect(report?.reportedProfileSnapshot).toBeNull();
|
||||
expect(report?.additionalInfo).toBeNull();
|
||||
expect(await indexedIds()).toContain(seeded.reportId.toString());
|
||||
for (const key of [seeded.attachmentKey, seeded.profileKey]) {
|
||||
if (key) expect(storage.hasObject(REPORTS_BUCKET, key)).toBe(false);
|
||||
}
|
||||
}
|
||||
|
||||
const summary = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY});
|
||||
function run(options: Partial<ReportRetentionOptions> = {}) {
|
||||
return processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY, ...options});
|
||||
}
|
||||
|
||||
it('deletes a report 365 days after it was resolved with its objects, search document and row', async () => {
|
||||
const expired = await seed({resolvedDaysAgo: 365, attachment: newAttachment(), profile: true});
|
||||
const kept = await seed({resolvedDaysAgo: 364, attachment: newAttachment(), profile: true});
|
||||
|
||||
const summary = await run();
|
||||
|
||||
await expectGone(expired);
|
||||
expect(storage.hasObject(REPORTS_BUCKET, expired.attachmentKey!)).toBe(false);
|
||||
@@ -211,31 +342,212 @@ describe('report retention', () => {
|
||||
expect(summary).toEqual({
|
||||
dryRun: false,
|
||||
scanned: 2,
|
||||
pending: 0,
|
||||
expired: 1,
|
||||
contextExpired: 0,
|
||||
held: 0,
|
||||
deleted: 1,
|
||||
contextCleared: 0,
|
||||
objectsDeleted: 2,
|
||||
sharedObjectsKept: 0,
|
||||
failed: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes expired reports whatever their status', async () => {
|
||||
const pending = await seed({ageDays: 400, status: 0});
|
||||
const resolved = await seed({ageDays: 400, status: 1});
|
||||
it('deletes the notes with an expired report and keeps the notes of a retained one', async () => {
|
||||
const expired = await seed({resolvedDaysAgo: 365});
|
||||
const contextExpired = await seed({resolvedDaysAgo: 200, outcome: 'no_violation', additionalInfo: 'details'});
|
||||
const kept = await seed({resolvedDaysAgo: 10});
|
||||
for (const {reportId} of [expired, contextExpired, kept]) {
|
||||
await repository.addNote({
|
||||
report_id: reportId,
|
||||
note_id: nextId(),
|
||||
author_id: nextId(),
|
||||
body: 'Staff context',
|
||||
created_at: NOW,
|
||||
});
|
||||
}
|
||||
|
||||
await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY});
|
||||
const summary = await run();
|
||||
|
||||
await expectGone(pending);
|
||||
await expectGone(resolved);
|
||||
expect(summary).toMatchObject({deleted: 1, contextCleared: 1});
|
||||
expect(await repository.listNotes(expired.reportId)).toEqual([]);
|
||||
expect(await repository.listNotes(contextExpired.reportId)).toHaveLength(1);
|
||||
expect(await repository.listNotes(kept.reportId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps the notes in a dry run', async () => {
|
||||
const expired = await seed({resolvedDaysAgo: 365});
|
||||
await repository.addNote({
|
||||
report_id: expired.reportId,
|
||||
note_id: nextId(),
|
||||
author_id: nextId(),
|
||||
body: 'Staff context',
|
||||
created_at: NOW,
|
||||
});
|
||||
|
||||
await run({dryRun: true});
|
||||
|
||||
expect(await repository.listNotes(expired.reportId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps a pending report however old it is', async () => {
|
||||
const pending = await seed({ageDays: 4000, attachment: newAttachment(), profile: true});
|
||||
|
||||
const summary = await run({policy: {retentionDays: 1}});
|
||||
|
||||
await expectKept(pending);
|
||||
expect(summary).toMatchObject({scanned: 1, pending: 1, expired: 0, deleted: 0, objectsDeleted: 0});
|
||||
});
|
||||
|
||||
it('drops the copied context of a no violation report after the complaint window and keeps the record', async () => {
|
||||
const outsideWindow = await seed({
|
||||
resolvedDaysAgo: REPORT_COMPLAINT_WINDOW_DAYS,
|
||||
outcome: 'no_violation',
|
||||
attachment: newAttachment(),
|
||||
profile: true,
|
||||
additionalInfo: 'They keep messaging my friends',
|
||||
});
|
||||
const insideWindow = await seed({
|
||||
resolvedDaysAgo: REPORT_COMPLAINT_WINDOW_DAYS - 1,
|
||||
outcome: 'no_violation',
|
||||
attachment: newAttachment(),
|
||||
profile: true,
|
||||
additionalInfo: 'Still open to a complaint',
|
||||
});
|
||||
const actioned = await seed({
|
||||
resolvedDaysAgo: REPORT_COMPLAINT_WINDOW_DAYS + 10,
|
||||
attachment: newAttachment(),
|
||||
profile: true,
|
||||
});
|
||||
|
||||
const summary = await run();
|
||||
|
||||
await expectContextCleared(outsideWindow);
|
||||
const cleared = await repository.getReport(outsideWindow.reportId);
|
||||
expect(cleared).toMatchObject({status: 1, category: 'other', reporterOutcome: 'no_violation'});
|
||||
expect((await search.searchReports('friends', {}, {limit: 10})).hits).toHaveLength(0);
|
||||
await expectKept(insideWindow);
|
||||
expect((await repository.getReport(insideWindow.reportId))?.additionalInfo).toBe('Still open to a complaint');
|
||||
await expectKept(actioned);
|
||||
expect(summary).toMatchObject({
|
||||
scanned: 3,
|
||||
expired: 0,
|
||||
contextExpired: 1,
|
||||
contextCleared: 1,
|
||||
deleted: 0,
|
||||
objectsDeleted: 2,
|
||||
failed: 0,
|
||||
});
|
||||
|
||||
const again = await run();
|
||||
expect(again).toMatchObject({contextExpired: 0, contextCleared: 0, objectsDeleted: 0});
|
||||
});
|
||||
|
||||
it('deletes a no violation report one year after it was resolved', async () => {
|
||||
const expired = await seed({resolvedDaysAgo: 365, outcome: 'no_violation', profile: true});
|
||||
|
||||
const summary = await run();
|
||||
|
||||
await expectGone(expired);
|
||||
expect(summary).toMatchObject({expired: 1, deleted: 1, contextExpired: 0});
|
||||
});
|
||||
|
||||
it('keeps an evidence copy that an actioned report shares with a no violation report', async () => {
|
||||
const shared = newAttachment();
|
||||
const noViolation = await seed({resolvedDaysAgo: 200, outcome: 'no_violation', attachment: shared});
|
||||
const actioned = await seed({resolvedDaysAgo: 200, attachment: shared});
|
||||
|
||||
const summary = await run();
|
||||
|
||||
expect((await repository.getReport(noViolation.reportId))?.messageContext).toBeNull();
|
||||
await expectKept(actioned);
|
||||
expect(summary).toMatchObject({contextCleared: 1, sharedObjectsKept: 1, objectsDeleted: 0});
|
||||
});
|
||||
|
||||
it('keeps a child safety report and its context for a year even with a shorter period or no violation', async () => {
|
||||
const childSafety = await seed({
|
||||
resolvedDaysAgo: 364,
|
||||
category: 'child_safety',
|
||||
outcome: 'no_violation',
|
||||
attachment: newAttachment(),
|
||||
profile: true,
|
||||
additionalInfo: 'Context for a CyberTip',
|
||||
});
|
||||
const childSafetyAfterYear = await seed({resolvedDaysAgo: 365, category: 'child_safety', profile: true});
|
||||
const ordinary = await seed({resolvedDaysAgo: 40, profile: true});
|
||||
|
||||
const summary = await run({policy: {retentionDays: 30}});
|
||||
|
||||
await expectKept(childSafety);
|
||||
expect((await repository.getReport(childSafety.reportId))?.additionalInfo).toBe('Context for a CyberTip');
|
||||
await expectGone(childSafetyAfterYear);
|
||||
await expectGone(ordinary);
|
||||
expect(summary).toMatchObject({expired: 2, contextExpired: 0, deleted: 2});
|
||||
});
|
||||
|
||||
it('keeps a child safety report under a legal hold past the year', async () => {
|
||||
const held = await seed({
|
||||
resolvedDaysAgo: 500,
|
||||
category: 'child_safety',
|
||||
profile: true,
|
||||
legalHoldUntil: new Date(NOW.getTime() + 30 * DAY_MS),
|
||||
});
|
||||
|
||||
const summary = await run();
|
||||
|
||||
await expectKept(held);
|
||||
expect(summary).toMatchObject({expired: 0, held: 1, deleted: 0});
|
||||
});
|
||||
|
||||
it('keeps a report linked to a decision until the decision evidence expires', async () => {
|
||||
const linked = await seed({
|
||||
resolvedDaysAgo: 400,
|
||||
outcome: 'no_violation',
|
||||
profile: true,
|
||||
attachment: newAttachment(),
|
||||
});
|
||||
const linkedLongAgo = await seed({resolvedDaysAgo: 800, profile: true});
|
||||
const unlinked = await seed({resolvedDaysAgo: 400, profile: true});
|
||||
await linkDecision([linked.reportId], daysAgo(500));
|
||||
await linkDecision([linked.reportId], daysAgo(100));
|
||||
await linkDecision([linkedLongAgo.reportId], daysAgo(700));
|
||||
|
||||
const summary = await run({pageSize: 1});
|
||||
|
||||
await expectKept(linked);
|
||||
expect((await repository.getReport(linked.reportId))?.messageContext).toHaveLength(1);
|
||||
await expectGone(linkedLongAgo);
|
||||
await expectGone(unlinked);
|
||||
expect(summary).toMatchObject({scanned: 3, expired: 2, contextExpired: 0, deleted: 2});
|
||||
});
|
||||
|
||||
it('deletes nothing when the decision scan cannot finish', async () => {
|
||||
const expired = await seed({resolvedDaysAgo: 400, profile: true});
|
||||
|
||||
await expect(
|
||||
processReportRetention(
|
||||
{
|
||||
...deps,
|
||||
decisionRepository: {
|
||||
listReportLinks: async () => {
|
||||
throw new Error('read timeout');
|
||||
},
|
||||
},
|
||||
},
|
||||
{now: NOW, dryRun: false, policy: DEFAULT_POLICY},
|
||||
),
|
||||
).rejects.toThrow('read timeout');
|
||||
|
||||
await expectKept(expired);
|
||||
});
|
||||
|
||||
it('keeps an object that a retained report still uses', async () => {
|
||||
const shared = newAttachment();
|
||||
const expired = await seed({ageDays: 500, attachment: shared});
|
||||
const retained = await seed({ageDays: 10, attachment: shared});
|
||||
const expired = await seed({resolvedDaysAgo: 500, attachment: shared});
|
||||
const retained = await seed({resolvedDaysAgo: 10, attachment: shared});
|
||||
|
||||
const summary = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY});
|
||||
const summary = await run();
|
||||
|
||||
await expectGone(expired);
|
||||
await expectKept(retained);
|
||||
@@ -246,10 +558,10 @@ describe('report retention', () => {
|
||||
|
||||
it('deletes an object shared by two expired reports once', async () => {
|
||||
const shared = newAttachment();
|
||||
const first = await seed({ageDays: 500, attachment: shared});
|
||||
const second = await seed({ageDays: 450, attachment: shared});
|
||||
const first = await seed({resolvedDaysAgo: 500, attachment: shared});
|
||||
const second = await seed({resolvedDaysAgo: 450, attachment: shared});
|
||||
|
||||
const summary = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY});
|
||||
const summary = await run();
|
||||
|
||||
await expectGone(first);
|
||||
await expectGone(second);
|
||||
@@ -261,15 +573,15 @@ describe('report retention', () => {
|
||||
it('keeps a report under a legal hold that ends later and deletes one whose hold has ended', async () => {
|
||||
const shared = newAttachment();
|
||||
const held = await seed({
|
||||
ageDays: 500,
|
||||
resolvedDaysAgo: 500,
|
||||
attachment: shared,
|
||||
profile: true,
|
||||
legalHoldUntil: new Date(NOW.getTime() + DAY_MS),
|
||||
});
|
||||
const holdEnded = await seed({ageDays: 500, profile: true, legalHoldUntil: daysAgo(1)});
|
||||
const sharingWithHeld = await seed({ageDays: 500, attachment: shared});
|
||||
const holdEnded = await seed({resolvedDaysAgo: 500, profile: true, legalHoldUntil: daysAgo(1)});
|
||||
const sharingWithHeld = await seed({resolvedDaysAgo: 500, attachment: shared});
|
||||
|
||||
const summary = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY});
|
||||
const summary = await run();
|
||||
|
||||
await expectKept(held);
|
||||
expect((await repository.getReport(held.reportId))?.legalHoldReason).toBe('Preservation request');
|
||||
@@ -279,31 +591,61 @@ describe('report retention', () => {
|
||||
expect(summary).toMatchObject({scanned: 3, expired: 2, held: 1, deleted: 2, sharedObjectsKept: 1});
|
||||
});
|
||||
|
||||
it('changes nothing in a dry run and reports what it would delete', async () => {
|
||||
const expired = await seed({ageDays: 366, attachment: newAttachment(), profile: true});
|
||||
const kept = await seed({ageDays: 1, profile: true});
|
||||
it('keeps the context of a no violation report under a legal hold', async () => {
|
||||
const held = await seed({
|
||||
resolvedDaysAgo: 250,
|
||||
outcome: 'no_violation',
|
||||
attachment: newAttachment(),
|
||||
legalHoldUntil: new Date(NOW.getTime() + DAY_MS),
|
||||
});
|
||||
|
||||
const summary = await processReportRetention(deps, {now: NOW, dryRun: true, policy: DEFAULT_POLICY});
|
||||
const summary = await run();
|
||||
|
||||
await expectKept(expired);
|
||||
await expectKept(kept);
|
||||
await expectKept(held);
|
||||
expect((await repository.getReport(held.reportId))?.messageContext).toHaveLength(1);
|
||||
expect(summary).toMatchObject({held: 1, contextExpired: 0, contextCleared: 0});
|
||||
});
|
||||
|
||||
it('changes nothing in a dry run and counts what it would do to existing reports', async () => {
|
||||
const expired = await seed({resolvedDaysAgo: 366, attachment: newAttachment(), profile: true});
|
||||
const contextDue = await seed({
|
||||
resolvedDaysAgo: 200,
|
||||
outcome: 'no_violation',
|
||||
attachment: newAttachment(),
|
||||
additionalInfo: 'details',
|
||||
});
|
||||
const childSafety = await seed({resolvedDaysAgo: 200, category: 'child_safety', outcome: 'no_violation'});
|
||||
const held = await seed({resolvedDaysAgo: 400, legalHoldUntil: new Date(NOW.getTime() + DAY_MS)});
|
||||
const pending = await seed({profile: true});
|
||||
const linked = await seed({resolvedDaysAgo: 400});
|
||||
await linkDecision([linked.reportId], daysAgo(30));
|
||||
|
||||
const summary = await run({dryRun: true});
|
||||
|
||||
for (const report of [expired, contextDue, childSafety, held, pending, linked]) {
|
||||
await expectKept(report);
|
||||
}
|
||||
expect((await repository.getReport(contextDue.reportId))?.additionalInfo).toBe('details');
|
||||
expect(storage.deleteObjectSpy).not.toHaveBeenCalled();
|
||||
expect(summary).toEqual({
|
||||
dryRun: true,
|
||||
scanned: 2,
|
||||
scanned: 6,
|
||||
pending: 1,
|
||||
expired: 1,
|
||||
held: 0,
|
||||
contextExpired: 1,
|
||||
held: 1,
|
||||
deleted: 1,
|
||||
objectsDeleted: 2,
|
||||
contextCleared: 1,
|
||||
objectsDeleted: 3,
|
||||
sharedObjectsKept: 0,
|
||||
failed: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes nothing when the scan cannot finish', async () => {
|
||||
const expired = await seed({ageDays: 400, attachment: newAttachment(), profile: true});
|
||||
await seed({ageDays: 400});
|
||||
await seed({ageDays: 400});
|
||||
const expired = await seed({resolvedDaysAgo: 400, attachment: newAttachment(), profile: true});
|
||||
await seed({resolvedDaysAgo: 400});
|
||||
await seed({resolvedDaysAgo: 400});
|
||||
let pages = 0;
|
||||
const failingRepository = Object.create(repository) as ReportRepository;
|
||||
failingRepository.listAllReportsPaginated = async (limit: number, lastReportId?: ReportID) => {
|
||||
@@ -315,7 +657,12 @@ describe('report retention', () => {
|
||||
await expect(
|
||||
processReportRetention(
|
||||
{...deps, reportRepository: failingRepository},
|
||||
{now: NOW, dryRun: false, policy: DEFAULT_POLICY, pageSize: 2},
|
||||
{
|
||||
now: NOW,
|
||||
dryRun: false,
|
||||
policy: DEFAULT_POLICY,
|
||||
pageSize: 2,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('read timeout');
|
||||
|
||||
@@ -328,11 +675,11 @@ describe('report retention', () => {
|
||||
const expired: Array<SeededReport> = [];
|
||||
const kept: Array<SeededReport> = [];
|
||||
for (let index = 0; index < 7; index++) {
|
||||
expired.push(await seed({ageDays: 400 + index, profile: true}));
|
||||
kept.push(await seed({ageDays: index, profile: true}));
|
||||
expired.push(await seed({resolvedDaysAgo: 400 + index, profile: true}));
|
||||
kept.push(await seed({resolvedDaysAgo: index, profile: true}));
|
||||
}
|
||||
|
||||
const summary = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY, pageSize: 3});
|
||||
const summary = await run({pageSize: 3});
|
||||
|
||||
expect(summary).toMatchObject({scanned: 14, expired: 7, deleted: 7, objectsDeleted: 7, failed: 0});
|
||||
for (const report of expired) await expectGone(report);
|
||||
@@ -340,25 +687,57 @@ describe('report retention', () => {
|
||||
});
|
||||
|
||||
it('keeps the row and search document when an object cannot be deleted, then deletes them on the next run', async () => {
|
||||
const expired = await seed({ageDays: 400, attachment: newAttachment(), profile: true});
|
||||
const expired = await seed({resolvedDaysAgo: 400, attachment: newAttachment(), profile: true});
|
||||
storage.configure({shouldFailDelete: true});
|
||||
|
||||
const failed = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY});
|
||||
const failed = await run();
|
||||
|
||||
expect(failed).toMatchObject({expired: 1, deleted: 0, failed: 1});
|
||||
expect(await repository.getReport(expired.reportId)).not.toBeNull();
|
||||
expect(await indexedIds()).toContain(expired.reportId.toString());
|
||||
|
||||
storage.configure({shouldFailDelete: false});
|
||||
const retried = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY});
|
||||
const retried = await run();
|
||||
|
||||
expect(retried).toMatchObject({expired: 1, deleted: 1, failed: 0});
|
||||
await expectGone(expired);
|
||||
expect(storage.hasObject(REPORTS_BUCKET, expired.attachmentKey!)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the copied context when an object cannot be deleted, then clears it on the next run', async () => {
|
||||
const due = await seed({resolvedDaysAgo: 200, outcome: 'no_violation', attachment: newAttachment()});
|
||||
storage.configure({shouldFailDelete: true});
|
||||
|
||||
expect(await run()).toMatchObject({contextExpired: 1, contextCleared: 0, failed: 1});
|
||||
expect((await repository.getReport(due.reportId))?.messageContext).toHaveLength(1);
|
||||
|
||||
storage.configure({shouldFailDelete: false});
|
||||
expect(await run()).toMatchObject({contextExpired: 1, contextCleared: 1, failed: 0});
|
||||
await expectContextCleared(due);
|
||||
});
|
||||
|
||||
it('does not bring back a report deleted while its context was being cleared', async () => {
|
||||
const due = await seed({resolvedDaysAgo: 200, outcome: 'no_violation', additionalInfo: 'details'});
|
||||
const deletingRepository = Object.create(repository) as ReportRepository;
|
||||
deletingRepository.clearCopiedContext = async (reportId, reportType) => {
|
||||
await repository.deleteReport(reportId);
|
||||
return repository.clearCopiedContext(reportId, reportType);
|
||||
};
|
||||
|
||||
await processReportRetention(
|
||||
{...deps, reportRepository: deletingRepository},
|
||||
{
|
||||
now: NOW,
|
||||
dryRun: false,
|
||||
policy: DEFAULT_POLICY,
|
||||
},
|
||||
);
|
||||
|
||||
expect(await repository.getReport(due.reportId)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the row when the search document cannot be deleted', async () => {
|
||||
const expired = await seed({ageDays: 400});
|
||||
const expired = await seed({resolvedDaysAgo: 400});
|
||||
const failingSearch = Object.create(search) as IReportSearchService;
|
||||
failingSearch.deleteReport = async () => {
|
||||
throw new Error('search unavailable');
|
||||
@@ -366,7 +745,11 @@ describe('report retention', () => {
|
||||
|
||||
const summary = await processReportRetention(
|
||||
{...deps, reportSearchService: failingSearch},
|
||||
{now: NOW, dryRun: false, policy: DEFAULT_POLICY},
|
||||
{
|
||||
now: NOW,
|
||||
dryRun: false,
|
||||
policy: DEFAULT_POLICY,
|
||||
},
|
||||
);
|
||||
|
||||
expect(summary).toMatchObject({deleted: 0, failed: 1});
|
||||
@@ -374,11 +757,15 @@ describe('report retention', () => {
|
||||
});
|
||||
|
||||
it('deletes rows when search is not configured', async () => {
|
||||
const expired = await seed({ageDays: 400, profile: true});
|
||||
const expired = await seed({resolvedDaysAgo: 400, profile: true});
|
||||
|
||||
const summary = await processReportRetention(
|
||||
{...deps, reportSearchService: null},
|
||||
{now: NOW, dryRun: false, policy: DEFAULT_POLICY},
|
||||
{
|
||||
now: NOW,
|
||||
dryRun: false,
|
||||
policy: DEFAULT_POLICY,
|
||||
},
|
||||
);
|
||||
|
||||
expect(summary).toMatchObject({deleted: 1, failed: 0});
|
||||
@@ -389,8 +776,8 @@ describe('report retention', () => {
|
||||
it('skips a report that was put on hold after the scan', async () => {
|
||||
const shared = newAttachment();
|
||||
const seeded = [
|
||||
await seed({ageDays: 400, attachment: shared, profile: true}),
|
||||
await seed({ageDays: 400, attachment: shared}),
|
||||
await seed({resolvedDaysAgo: 400, attachment: shared, profile: true}),
|
||||
await seed({resolvedDaysAgo: 400, attachment: shared}),
|
||||
];
|
||||
const scanOrder = (await repository.listAllReportsPaginated(10)).map((report) => report.reportId);
|
||||
const [first, second] = seeded.sort((a, b) => scanOrder.indexOf(a.reportId) - scanOrder.indexOf(b.reportId)) as [
|
||||
@@ -408,7 +795,11 @@ describe('report retention', () => {
|
||||
|
||||
const summary = await processReportRetention(
|
||||
{...deps, reportRepository: holdingRepository},
|
||||
{now: NOW, dryRun: false, policy: DEFAULT_POLICY},
|
||||
{
|
||||
now: NOW,
|
||||
dryRun: false,
|
||||
policy: DEFAULT_POLICY,
|
||||
},
|
||||
);
|
||||
|
||||
await expectKept(first);
|
||||
@@ -419,7 +810,7 @@ describe('report retention', () => {
|
||||
|
||||
it('keeps an object that a report filed during the run stored again', async () => {
|
||||
const shared = newAttachment();
|
||||
const expired = await seed({ageDays: 400, attachment: shared, profile: true});
|
||||
const expired = await seed({resolvedDaysAgo: 400, attachment: shared, profile: true});
|
||||
const rewritingStorage = Object.create(storage) as MockStorageService;
|
||||
rewritingStorage.getObjectMetadata = async (bucket: string, key: string) => {
|
||||
const metadata = await storage.getObjectMetadata(bucket, key);
|
||||
@@ -431,7 +822,11 @@ describe('report retention', () => {
|
||||
|
||||
const summary = await processReportRetention(
|
||||
{...deps, storageService: rewritingStorage},
|
||||
{now: NOW, dryRun: false, policy: DEFAULT_POLICY},
|
||||
{
|
||||
now: NOW,
|
||||
dryRun: false,
|
||||
policy: DEFAULT_POLICY,
|
||||
},
|
||||
);
|
||||
|
||||
await expectGone(expired);
|
||||
@@ -440,76 +835,25 @@ describe('report retention', () => {
|
||||
});
|
||||
|
||||
it('deletes profile images stored for a report whose snapshot cannot be read', async () => {
|
||||
const expired = await seed({ageDays: 400, profile: true, snapshotJson: '{"not":"a snapshot"'});
|
||||
const expired = await seed({resolvedDaysAgo: 400, profile: true, snapshotJson: '{"not":"a snapshot"'});
|
||||
|
||||
const summary = await processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY});
|
||||
const summary = await run();
|
||||
|
||||
expect(summary).toMatchObject({deleted: 1, objectsDeleted: 1});
|
||||
await expectGone(expired);
|
||||
});
|
||||
|
||||
function run(options: Partial<ReportRetentionOptions> = {}) {
|
||||
return processReportRetention(deps, {now: NOW, dryRun: false, policy: DEFAULT_POLICY, ...options});
|
||||
}
|
||||
|
||||
it('deletes earlier when the retention period is shorter', async () => {
|
||||
const expired = await seed({ageDays: 30, profile: true});
|
||||
const kept = await seed({ageDays: 29, profile: true});
|
||||
const expired = await seed({resolvedDaysAgo: 30, profile: true});
|
||||
const kept = await seed({resolvedDaysAgo: 29, profile: true});
|
||||
|
||||
const summary = await run({policy: {retentionDays: 30, resolvedRetentionDays: null}});
|
||||
const summary = await run({policy: {retentionDays: 30}});
|
||||
|
||||
await expectGone(expired);
|
||||
await expectKept(kept);
|
||||
expect(summary).toMatchObject({scanned: 2, expired: 1, deleted: 1});
|
||||
});
|
||||
|
||||
it('deletes a resolved report once the resolved-report period has passed since it was resolved', async () => {
|
||||
const policy = {retentionDays: 365, resolvedRetentionDays: 30};
|
||||
const resolvedLongAgo = await seed({ageDays: 100, resolvedDaysAgo: 30, attachment: newAttachment()});
|
||||
const resolvedRecently = await seed({ageDays: 100, resolvedDaysAgo: 29, profile: true});
|
||||
const pending = await seed({ageDays: 100, profile: true});
|
||||
const resolvedWithoutTime = await seed({ageDays: 100, status: 1, profile: true});
|
||||
|
||||
const summary = await run({policy});
|
||||
|
||||
await expectGone(resolvedLongAgo);
|
||||
expect(storage.hasObject(REPORTS_BUCKET, resolvedLongAgo.attachmentKey!)).toBe(false);
|
||||
await expectKept(resolvedRecently);
|
||||
await expectKept(pending);
|
||||
await expectKept(resolvedWithoutTime);
|
||||
expect(summary).toMatchObject({scanned: 4, expired: 1, deleted: 1, held: 0});
|
||||
});
|
||||
|
||||
it('applies whichever rule ends first', async () => {
|
||||
const policy = {retentionDays: 100, resolvedRetentionDays: 300};
|
||||
const byAge = await seed({ageDays: 100, resolvedDaysAgo: 10});
|
||||
const neither = await seed({ageDays: 99, resolvedDaysAgo: 90});
|
||||
|
||||
await run({policy});
|
||||
|
||||
await expectGone(byAge);
|
||||
await expectKept(neither);
|
||||
});
|
||||
|
||||
it('keeps a held resolved report past the resolved-report period and keeps its evidence', async () => {
|
||||
const shared = newAttachment();
|
||||
const held = await seed({
|
||||
ageDays: 50,
|
||||
resolvedDaysAgo: 40,
|
||||
attachment: shared,
|
||||
profile: true,
|
||||
legalHoldUntil: new Date(NOW.getTime() + DAY_MS),
|
||||
});
|
||||
const sharingWithHeld = await seed({ageDays: 50, resolvedDaysAgo: 40, attachment: shared});
|
||||
|
||||
const summary = await run({policy: {retentionDays: 365, resolvedRetentionDays: 7}});
|
||||
|
||||
await expectKept(held);
|
||||
await expectGone(sharingWithHeld);
|
||||
expect(storage.hasObject(REPORTS_BUCKET, attachmentKey(shared))).toBe(true);
|
||||
expect(summary).toMatchObject({expired: 1, held: 1, deleted: 1, sharedObjectsKept: 1});
|
||||
});
|
||||
|
||||
it('frees the reporter to report the same account again once the report is deleted', async () => {
|
||||
const reporterId = nextId();
|
||||
const reportedUserId = nextId();
|
||||
@@ -519,8 +863,9 @@ describe('report retention', () => {
|
||||
report_id: BigInt(reportId),
|
||||
reporter_id: reporterId,
|
||||
reported_user_id: reportedUserId,
|
||||
reported_at: daysAgo(400),
|
||||
status: 0,
|
||||
reported_at: daysAgo(420),
|
||||
status: 1,
|
||||
resolved_at: daysAgo(400),
|
||||
report_type: 1,
|
||||
category: 'harassment',
|
||||
} as IARSubmissionRow);
|
||||
@@ -528,7 +873,7 @@ describe('report retention', () => {
|
||||
reporter_id: createUserID(reporterId),
|
||||
reported_user_id: createUserID(reportedUserId),
|
||||
report_id: reportId,
|
||||
reported_at: daysAgo(400),
|
||||
reported_at: daysAgo(420),
|
||||
};
|
||||
expect(await repository.reserveUserReportByReporter(reservation)).toBe(true);
|
||||
|
||||
@@ -559,9 +904,10 @@ describe('report retention', () => {
|
||||
}
|
||||
|
||||
it('deletes for real with the default settings', async () => {
|
||||
expect(configured).toEqual({days: 365, resolvedDays: null, dryRun: false});
|
||||
const expired = await seed({ageDays: 366, attachment: newAttachment(), profile: true});
|
||||
const kept = await seed({ageDays: 364, profile: true});
|
||||
expect(configured).toEqual({days: 365, dryRun: false});
|
||||
const expired = await seed({resolvedDaysAgo: 366, attachment: newAttachment(), profile: true});
|
||||
const kept = await seed({resolvedDaysAgo: 364, profile: true});
|
||||
const pending = await seed({profile: true});
|
||||
|
||||
await runDailyTask();
|
||||
|
||||
@@ -569,11 +915,12 @@ describe('report retention', () => {
|
||||
expect(storage.hasObject(REPORTS_BUCKET, expired.attachmentKey!)).toBe(false);
|
||||
expect(storage.hasObject(REPORTS_BUCKET, expired.profileKey!)).toBe(false);
|
||||
expect(await repository.getReport(kept.reportId)).not.toBeNull();
|
||||
expect(await repository.getReport(pending.reportId)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('only counts what it would delete when FLUXER_REPORT_RETENTION_DRY_RUN is true', async () => {
|
||||
Object.assign(Config.reportRetention, {dryRun: true});
|
||||
const expired = await seed({ageDays: 366, attachment: newAttachment(), profile: true});
|
||||
const expired = await seed({resolvedDaysAgo: 366, attachment: newAttachment(), profile: true});
|
||||
|
||||
await runDailyTask();
|
||||
|
||||
@@ -581,17 +928,18 @@ describe('report retention', () => {
|
||||
expect(storage.deleteObjectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads both day settings from the config', async () => {
|
||||
Object.assign(Config.reportRetention, {days: 60, resolvedDays: 5});
|
||||
const byAge = await seed({ageDays: 60});
|
||||
const byResolution = await seed({ageDays: 20, resolvedDaysAgo: 5});
|
||||
const kept = await seed({ageDays: 20, resolvedDaysAgo: 4});
|
||||
it('reads the day setting from the config and honors decisions', async () => {
|
||||
Object.assign(Config.reportRetention, {days: 60});
|
||||
const expired = await seed({resolvedDaysAgo: 60});
|
||||
const kept = await seed({resolvedDaysAgo: 59});
|
||||
const linked = await seed({resolvedDaysAgo: 90});
|
||||
await linkDecision([linked.reportId], daysAgo(90));
|
||||
|
||||
await runDailyTask();
|
||||
|
||||
expect(await repository.getReport(byAge.reportId)).toBeNull();
|
||||
expect(await repository.getReport(byResolution.reportId)).toBeNull();
|
||||
expect(await repository.getReport(expired.reportId)).toBeNull();
|
||||
expect(await repository.getReport(kept.reportId)).not.toBeNull();
|
||||
expect(await repository.getReport(linked.reportId)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,24 +2,17 @@
|
||||
|
||||
import {mkdirSync, writeFileSync} from 'node:fs';
|
||||
import {dirname, join, relative} from 'node:path';
|
||||
|
||||
interface ColorFamily {
|
||||
hue: number;
|
||||
saturation: number;
|
||||
useSaturationFactor: boolean;
|
||||
}
|
||||
|
||||
interface ScaleStop {
|
||||
name: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
interface Scale {
|
||||
family: string;
|
||||
range: [number, number];
|
||||
curve: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut';
|
||||
stops: Array<ScaleStop>;
|
||||
}
|
||||
import {
|
||||
BRAND_PRESSED_RATIO,
|
||||
brandTintMix,
|
||||
COLOR_FAMILIES,
|
||||
COLOR_SCALES,
|
||||
type ColorFamily,
|
||||
type Scale,
|
||||
scaleStopLightness,
|
||||
THEME_PALETTES,
|
||||
} from '@app/features/theme/utils/ColorSystemPalette';
|
||||
import {ThemeTypes} from '@fluxer/constants/src/UserConstants';
|
||||
|
||||
interface TokenDef {
|
||||
name?: string;
|
||||
@@ -295,142 +288,8 @@ const LIGHT_CODE_TOKENS: Array<TokenDef> = [
|
||||
{name: '--ansi-bg-bright-white', value: 'color-mix(in srgb, hsl(0, 0%, 90%) 90%, var(--code-block-bg) 10%)'},
|
||||
];
|
||||
const CONFIG: Config = {
|
||||
families: {
|
||||
neutralDark: {hue: 258, saturation: 10, useSaturationFactor: true},
|
||||
neutralLight: {hue: 220, saturation: 10, useSaturationFactor: true},
|
||||
brand: {hue: 242, saturation: 70, useSaturationFactor: true},
|
||||
link: {hue: 198, saturation: 92, useSaturationFactor: true},
|
||||
accentPurple: {hue: 278, saturation: 85, useSaturationFactor: true},
|
||||
statusOnline: {hue: 152, saturation: 72, useSaturationFactor: true},
|
||||
legacyDark: {hue: 220, saturation: 13, useSaturationFactor: true},
|
||||
legacyLink: {hue: 210, saturation: 100, useSaturationFactor: true},
|
||||
legacyAccentPurple: {hue: 270, saturation: 80, useSaturationFactor: true},
|
||||
legacyStatusOnline: {hue: 142, saturation: 76, useSaturationFactor: true},
|
||||
statusIdle: {hue: 45, saturation: 93, useSaturationFactor: true},
|
||||
statusDnd: {hue: 0, saturation: 84, useSaturationFactor: true},
|
||||
statusOffline: {hue: 218, saturation: 11, useSaturationFactor: true},
|
||||
statusDanger: {hue: 1, saturation: 77, useSaturationFactor: true},
|
||||
textCode: {hue: 340, saturation: 50, useSaturationFactor: true},
|
||||
brandIcon: {hue: 38, saturation: 92, useSaturationFactor: true},
|
||||
},
|
||||
scales: {
|
||||
darkSurface: {
|
||||
family: 'neutralDark',
|
||||
range: [5, 24],
|
||||
curve: 'easeOut',
|
||||
stops: [
|
||||
{name: '--background-primary', position: 0},
|
||||
{name: '--background-secondary', position: 0.16},
|
||||
{name: '--background-secondary-lighter', position: 0.22},
|
||||
{name: '--background-secondary-alt', position: 0.28},
|
||||
{name: '--background-tertiary', position: 0.4},
|
||||
{name: '--background-channel-header', position: 0.34},
|
||||
{name: '--guild-list-foreground', position: 0.38},
|
||||
{name: '--background-header-secondary', position: 0.5},
|
||||
{name: '--background-header-primary', position: 0.5},
|
||||
{name: '--background-textarea', position: 0.3},
|
||||
{name: '--background-header-primary-hover', position: 0.85},
|
||||
],
|
||||
},
|
||||
coalSurface: {
|
||||
family: 'neutralDark',
|
||||
range: [1, 12],
|
||||
curve: 'easeOut',
|
||||
stops: [
|
||||
{name: '--background-primary', position: 0},
|
||||
{name: '--background-secondary', position: 0.16},
|
||||
{name: '--background-secondary-alt', position: 0.28},
|
||||
{name: '--background-tertiary', position: 0.4},
|
||||
{name: '--background-channel-header', position: 0.34},
|
||||
{name: '--guild-list-foreground', position: 0.38},
|
||||
{name: '--background-header-secondary', position: 0.5},
|
||||
{name: '--background-header-primary', position: 0.5},
|
||||
{name: '--background-textarea', position: 0.3},
|
||||
{name: '--background-header-primary-hover', position: 0.85},
|
||||
],
|
||||
},
|
||||
darkText: {
|
||||
family: 'neutralDark',
|
||||
range: [60, 96],
|
||||
curve: 'easeInOut',
|
||||
stops: [
|
||||
{name: '--text-tertiary-secondary', position: 0},
|
||||
{name: '--text-tertiary-muted', position: 0.2},
|
||||
{name: '--text-tertiary', position: 0.38},
|
||||
{name: '--text-primary-muted', position: 0.55},
|
||||
{name: '--text-chat-muted', position: 0.55},
|
||||
{name: '--text-secondary', position: 0.72},
|
||||
{name: '--text-chat', position: 0.82},
|
||||
{name: '--text-primary', position: 1},
|
||||
],
|
||||
},
|
||||
lightSurface: {
|
||||
family: 'neutralLight',
|
||||
range: [86, 98.5],
|
||||
curve: 'easeIn',
|
||||
stops: [
|
||||
{name: '--background-header-primary-hover', position: 0},
|
||||
{name: '--background-header-primary', position: 0.12},
|
||||
{name: '--background-header-secondary', position: 0.2},
|
||||
{name: '--guild-list-foreground', position: 0.35},
|
||||
{name: '--background-tertiary', position: 0.42},
|
||||
{name: '--background-channel-header', position: 0.5},
|
||||
{name: '--background-secondary-alt', position: 0.63},
|
||||
{name: '--background-secondary', position: 0.74},
|
||||
{name: '--background-secondary-lighter', position: 0.83},
|
||||
{name: '--background-textarea', position: 0.88},
|
||||
{name: '--background-primary', position: 1},
|
||||
],
|
||||
},
|
||||
lightText: {
|
||||
family: 'neutralLight',
|
||||
range: [15, 54],
|
||||
curve: 'easeOut',
|
||||
stops: [
|
||||
{name: '--text-primary', position: 0},
|
||||
{name: '--text-chat', position: 0.08},
|
||||
{name: '--text-secondary', position: 0.28},
|
||||
{name: '--text-chat-muted', position: 0.45},
|
||||
{name: '--text-primary-muted', position: 0.45},
|
||||
{name: '--text-tertiary', position: 0.6},
|
||||
{name: '--text-tertiary-secondary', position: 0.75},
|
||||
{name: '--text-tertiary-muted', position: 0.85},
|
||||
],
|
||||
},
|
||||
legacyDarkSurface: {
|
||||
family: 'legacyDark',
|
||||
range: [5, 26],
|
||||
curve: 'easeOut',
|
||||
stops: [
|
||||
{name: '--background-primary', position: 0},
|
||||
{name: '--background-secondary', position: 0.16},
|
||||
{name: '--background-secondary-lighter', position: 0.22},
|
||||
{name: '--background-secondary-alt', position: 0.28},
|
||||
{name: '--background-tertiary', position: 0.4},
|
||||
{name: '--background-channel-header', position: 0.34},
|
||||
{name: '--guild-list-foreground', position: 0.38},
|
||||
{name: '--background-header-secondary', position: 0.5},
|
||||
{name: '--background-header-primary', position: 0.5},
|
||||
{name: '--background-textarea', position: 0.3},
|
||||
{name: '--background-header-primary-hover', position: 0.85},
|
||||
],
|
||||
},
|
||||
legacyDarkText: {
|
||||
family: 'legacyDark',
|
||||
range: [52, 96],
|
||||
curve: 'easeInOut',
|
||||
stops: [
|
||||
{name: '--text-tertiary-secondary', position: 0},
|
||||
{name: '--text-tertiary-muted', position: 0.2},
|
||||
{name: '--text-tertiary', position: 0.38},
|
||||
{name: '--text-primary-muted', position: 0.55},
|
||||
{name: '--text-chat-muted', position: 0.55},
|
||||
{name: '--text-secondary', position: 0.72},
|
||||
{name: '--text-chat', position: 0.82},
|
||||
{name: '--text-primary', position: 1},
|
||||
],
|
||||
},
|
||||
},
|
||||
families: COLOR_FAMILIES,
|
||||
scales: COLOR_SCALES,
|
||||
tokens: {
|
||||
root: [
|
||||
{scale: 'darkSurface'},
|
||||
@@ -447,7 +306,7 @@ hsl(258, calc(10% * var(--saturation-factor)), 2%) 10%
|
||||
{name: '--panel-control-divider', family: 'neutralDark', saturation: 30, lightness: 55, alpha: 0.35},
|
||||
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.04)'},
|
||||
{name: '--background-modifier-hover', family: 'neutralDark', lightness: 100, alpha: 0.05},
|
||||
{name: '--background-modifier-selected', family: 'neutralDark', lightness: 100, alpha: 0.1},
|
||||
{name: '--background-modifier-selected', ...THEME_PALETTES[ThemeTypes.DARK].selectedOverlay},
|
||||
{name: '--background-modifier-accent', family: 'neutralDark', saturation: 13, lightness: 80, alpha: 0.15},
|
||||
{name: '--background-modifier-accent-focus', family: 'neutralDark', saturation: 13, lightness: 80, alpha: 0.22},
|
||||
{name: '--control-button-normal-bg', value: 'transparent'},
|
||||
@@ -480,6 +339,41 @@ hsl(258, calc(10% * var(--saturation-factor)), 2%) 10%
|
||||
lightness: 100,
|
||||
instanceOverride: '--instance-brand-primary-fill',
|
||||
},
|
||||
{
|
||||
name: '--brand-primary-shade',
|
||||
hue: 0,
|
||||
saturation: 0,
|
||||
lightness: 0,
|
||||
instanceOverride: '--instance-brand-primary-shade',
|
||||
},
|
||||
{
|
||||
name: '--brand-primary-pressed',
|
||||
value: `color-mix(in srgb, var(--brand-primary) ${BRAND_PRESSED_RATIO}%, var(--brand-primary-shade))`,
|
||||
},
|
||||
{
|
||||
name: '--brand-primary-foreground',
|
||||
family: 'brand',
|
||||
lightness: 55,
|
||||
instanceOverride: '--instance-brand-primary-foreground',
|
||||
},
|
||||
{
|
||||
name: '--brand-primary-light-fill',
|
||||
hue: 0,
|
||||
saturation: 0,
|
||||
lightness: 100,
|
||||
instanceOverride: '--instance-brand-primary-light-fill',
|
||||
},
|
||||
{
|
||||
name: '--brand-tint',
|
||||
value: brandTintMix(THEME_PALETTES[ThemeTypes.DARK].brandTint, 'rest'),
|
||||
instanceOverride: '--instance-brand-tint',
|
||||
},
|
||||
{
|
||||
name: '--brand-tint-hover',
|
||||
value: brandTintMix(THEME_PALETTES[ThemeTypes.DARK].brandTint, 'hover'),
|
||||
instanceOverride: '--instance-brand-tint-hover',
|
||||
},
|
||||
{name: '--brand-tint-border', value: 'var(--brand-primary)', instanceOverride: '--instance-brand-tint-border'},
|
||||
{name: '--status-online', family: 'statusOnline', lightness: 40},
|
||||
{name: '--status-idle', family: 'statusIdle', lightness: 50},
|
||||
{name: '--status-dnd', family: 'statusDnd', lightness: 60},
|
||||
@@ -500,6 +394,19 @@ hsl(258, calc(10% * var(--saturation-factor)), 2%) 10%
|
||||
lightness: 98,
|
||||
instanceOverride: '--instance-text-on-brand-primary',
|
||||
},
|
||||
{
|
||||
name: '--text-on-brand-primary-light',
|
||||
hue: 0,
|
||||
saturation: 0,
|
||||
lightness: 98,
|
||||
instanceOverride: '--instance-text-on-brand-primary-light',
|
||||
},
|
||||
{
|
||||
name: '--text-on-brand-tint',
|
||||
value: 'var(--text-on-brand-primary)',
|
||||
instanceOverride: '--instance-text-on-brand-tint',
|
||||
},
|
||||
{name: '--text-on-dark', hue: 0, saturation: 0, lightness: 98},
|
||||
...DARK_CODE_TOKENS,
|
||||
{name: '--text-selection', hue: 198, saturation: 92, useSaturationFactor: true, lightness: 70, alpha: 0.35},
|
||||
{name: '--markup-mention-text', value: 'var(--text-link)'},
|
||||
@@ -623,7 +530,7 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
|
||||
{name: '--panel-control-divider', family: 'neutralLight', saturation: 30, lightness: 35, alpha: 0.2},
|
||||
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.65)'},
|
||||
{name: '--background-modifier-hover', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.05},
|
||||
{name: '--background-modifier-selected', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.1},
|
||||
{name: '--background-modifier-selected', ...THEME_PALETTES[ThemeTypes.LIGHT].selectedOverlay},
|
||||
{name: '--background-modifier-accent', family: 'neutralLight', saturation: 10, lightness: 40, alpha: 0.22},
|
||||
{name: '--background-modifier-accent-focus', family: 'neutralLight', saturation: 10, lightness: 40, alpha: 0.32},
|
||||
{name: '--control-button-normal-bg', value: 'transparent'},
|
||||
@@ -667,6 +574,22 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
|
||||
{name: '--plutonium-text', value: 'var(--text-on-brand-primary)'},
|
||||
{name: '--plutonium-icon', family: 'brandIcon', lightness: 45},
|
||||
{name: '--invite-verified-icon-color', value: 'var(--brand-primary)'},
|
||||
{
|
||||
name: '--brand-tint',
|
||||
value: brandTintMix(THEME_PALETTES[ThemeTypes.LIGHT].brandTint, 'rest'),
|
||||
instanceOverride: '--instance-brand-tint',
|
||||
},
|
||||
{
|
||||
name: '--brand-tint-hover',
|
||||
value: brandTintMix(THEME_PALETTES[ThemeTypes.LIGHT].brandTint, 'hover'),
|
||||
instanceOverride: '--instance-brand-tint-hover',
|
||||
},
|
||||
{
|
||||
name: '--brand-tint-border',
|
||||
value: 'color-mix(in srgb, var(--brand-primary) 45%, transparent 55%)',
|
||||
instanceOverride: '--instance-brand-tint-border',
|
||||
},
|
||||
{name: '--text-on-brand-tint', value: 'var(--brand-primary)', instanceOverride: '--instance-text-on-brand-tint'},
|
||||
{name: '--border-color', family: 'neutralLight', lightness: 40, alpha: 0.15},
|
||||
{name: '--border-color-hover', family: 'neutralLight', lightness: 40, alpha: 0.25},
|
||||
{name: '--border-color-focus', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 50, alpha: 0.4},
|
||||
@@ -707,8 +630,10 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
|
||||
],
|
||||
coal: [
|
||||
{scale: 'coalSurface'},
|
||||
{name: '--background-secondary', value: 'var(--background-primary)'},
|
||||
{name: '--background-secondary-lighter', value: 'var(--background-primary)'},
|
||||
...Object.entries(THEME_PALETTES[ThemeTypes.COAL].surfaceAliases).map(([name, target]) => ({
|
||||
name,
|
||||
value: `var(${target})`,
|
||||
})),
|
||||
{
|
||||
name: '--panel-control-bg',
|
||||
value: `color-mix(
|
||||
@@ -721,7 +646,7 @@ hsl(258, calc(10% * var(--saturation-factor)), 0%) 10%
|
||||
{name: '--panel-control-divider', family: 'neutralDark', saturation: 20, lightness: 25, alpha: 0.28},
|
||||
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.06)'},
|
||||
{name: '--background-modifier-hover', family: 'neutralDark', lightness: 100, alpha: 0.04},
|
||||
{name: '--background-modifier-selected', family: 'neutralDark', lightness: 100, alpha: 0.08},
|
||||
{name: '--background-modifier-selected', ...THEME_PALETTES[ThemeTypes.COAL].selectedOverlay},
|
||||
{name: '--background-modifier-accent', family: 'neutralDark', saturation: 10, lightness: 65, alpha: 0.18},
|
||||
{name: '--background-modifier-accent-focus', family: 'neutralDark', saturation: 10, lightness: 70, alpha: 0.26},
|
||||
{name: '--control-button-normal-bg', value: 'transparent'},
|
||||
@@ -776,7 +701,7 @@ hsl(220, calc(13% * var(--saturation-factor)), 2%) 10%
|
||||
{name: '--panel-control-border', family: 'legacyDark', saturation: 30, lightness: 65, alpha: 0.45},
|
||||
{name: '--panel-control-divider', family: 'legacyDark', saturation: 30, lightness: 55, alpha: 0.35},
|
||||
{name: '--background-modifier-hover', family: 'legacyDark', lightness: 100, alpha: 0.05},
|
||||
{name: '--background-modifier-selected', family: 'legacyDark', lightness: 100, alpha: 0.1},
|
||||
{name: '--background-modifier-selected', ...THEME_PALETTES[ThemeTypes.DARK_LEGACY].selectedOverlay},
|
||||
{name: '--background-modifier-accent', family: 'legacyDark', saturation: 13, lightness: 80, alpha: 0.15},
|
||||
{name: '--background-modifier-accent-focus', family: 'legacyDark', saturation: 13, lightness: 80, alpha: 0.22},
|
||||
{name: '--control-button-hover-bg', family: 'legacyDark', lightness: 22},
|
||||
@@ -820,48 +745,13 @@ interface OutputToken {
|
||||
instanceOverride?: string;
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
|
||||
function applyCurve(curve: Scale['curve'], t: number): number {
|
||||
switch (curve) {
|
||||
case 'easeIn':
|
||||
return t * t;
|
||||
case 'easeOut':
|
||||
return 1 - (1 - t) * (1 - t);
|
||||
case 'easeInOut':
|
||||
if (t < 0.5) {
|
||||
return 2 * t * t;
|
||||
}
|
||||
return 1 - 2 * (1 - t) * (1 - t);
|
||||
default:
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
function buildScaleTokens(scale: Scale): Array<OutputToken> {
|
||||
const lastIndex = Math.max(scale.stops.length - 1, 1);
|
||||
const tokens: Array<OutputToken> = [];
|
||||
for (let i = 0; i < scale.stops.length; i++) {
|
||||
const stop = scale.stops[i];
|
||||
let pos: number;
|
||||
if (stop.position !== undefined) {
|
||||
pos = clamp01(stop.position);
|
||||
} else {
|
||||
pos = i / lastIndex;
|
||||
}
|
||||
const eased = applyCurve(scale.curve, pos);
|
||||
let lightness = scale.range[0] + (scale.range[1] - scale.range[0]) * eased;
|
||||
lightness = Math.round(lightness * 1000) / 1000;
|
||||
tokens.push({
|
||||
type: 'tone',
|
||||
name: stop.name,
|
||||
family: scale.family,
|
||||
lightness,
|
||||
});
|
||||
}
|
||||
return tokens;
|
||||
return scale.stops.map((stop, index) => ({
|
||||
type: 'tone',
|
||||
name: stop.name,
|
||||
family: scale.family,
|
||||
lightness: scaleStopLightness(scale, index),
|
||||
}));
|
||||
}
|
||||
|
||||
function expandTokens(defs: Array<TokenDef>, scales: Record<string, Scale>): Array<OutputToken> {
|
||||
@@ -881,6 +771,7 @@ function expandTokens(defs: Array<TokenDef>, scales: Record<string, Scale>): Arr
|
||||
type: 'literal',
|
||||
name: def.name!,
|
||||
value: def.value.trim(),
|
||||
instanceOverride: def.instanceOverride,
|
||||
});
|
||||
} else {
|
||||
tokens.push({
|
||||
@@ -945,11 +836,8 @@ function formatTone(token: OutputToken, families: Record<string, ColorFamily>):
|
||||
}
|
||||
|
||||
function formatValue(token: OutputToken, families: Record<string, ColorFamily>): string {
|
||||
if (token.type === 'tone') {
|
||||
const tone = formatTone(token, families);
|
||||
return token.instanceOverride ? `var(${token.instanceOverride}, ${tone})` : tone;
|
||||
}
|
||||
return token.value!.trim();
|
||||
const value = token.type === 'tone' ? formatTone(token, families) : token.value!.trim();
|
||||
return token.instanceOverride ? `var(${token.instanceOverride}, ${value})` : value;
|
||||
}
|
||||
|
||||
function renderBlock(selector: string, tokens: Array<OutputToken>, families: Record<string, ColorFamily>): string {
|
||||
|
||||
@@ -201,7 +201,7 @@ html.platform-native .fluxer-boot-app {
|
||||
.fluxer-boot-item[data-selected='1'] .fluxer-boot-fluxer-icon {
|
||||
border-radius: 30%;
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
.fluxer-boot-action-icon {
|
||||
display: flex;
|
||||
@@ -218,7 +218,7 @@ html.platform-native .fluxer-boot-app {
|
||||
.fluxer-boot-item[data-selected='1'] .fluxer-boot-action-icon {
|
||||
border-radius: 30%;
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
.fluxer-boot-glyph-lg {
|
||||
height: 1.75rem;
|
||||
|
||||
@@ -650,6 +650,7 @@ export interface AccessibilitySettings {
|
||||
preserveEditDraft: boolean;
|
||||
stayInteractiveWhenUnfocused: boolean;
|
||||
scrollToBottomOnMessageSend: boolean;
|
||||
doubleClickMessageActions: boolean;
|
||||
sequentialFileSend: boolean;
|
||||
showNeko: boolean;
|
||||
keepNekoStill: boolean;
|
||||
@@ -756,6 +757,7 @@ class Accessibility {
|
||||
preserveEditDraft = false;
|
||||
stayInteractiveWhenUnfocused = false;
|
||||
scrollToBottomOnMessageSend = true;
|
||||
doubleClickMessageActions = true;
|
||||
sequentialFileSend = false;
|
||||
showNeko = false;
|
||||
keepNekoStill = false;
|
||||
@@ -858,6 +860,7 @@ class Accessibility {
|
||||
'preserveEditDraft',
|
||||
'stayInteractiveWhenUnfocused',
|
||||
'scrollToBottomOnMessageSend',
|
||||
'doubleClickMessageActions',
|
||||
'sequentialFileSend',
|
||||
],
|
||||
toMessage: (s) => ({
|
||||
@@ -920,6 +923,7 @@ class Accessibility {
|
||||
preserveEditDraft: s.preserveEditDraft,
|
||||
stayInteractiveWhenUnfocused: s.stayInteractiveWhenUnfocused,
|
||||
scrollToBottomOnMessageSend: s.scrollToBottomOnMessageSend,
|
||||
doubleClickMessageActions: s.doubleClickMessageActions,
|
||||
sequentialFileSend: s.sequentialFileSend,
|
||||
}),
|
||||
applyMessage: (s, m) => {
|
||||
@@ -1012,6 +1016,7 @@ class Accessibility {
|
||||
if (m.stayInteractiveWhenUnfocused !== undefined)
|
||||
s.stayInteractiveWhenUnfocused = m.stayInteractiveWhenUnfocused;
|
||||
if (m.scrollToBottomOnMessageSend !== undefined) s.scrollToBottomOnMessageSend = m.scrollToBottomOnMessageSend;
|
||||
if (m.doubleClickMessageActions !== undefined) s.doubleClickMessageActions = m.doubleClickMessageActions;
|
||||
if (m.sequentialFileSend !== undefined) s.sequentialFileSend = m.sequentialFileSend;
|
||||
},
|
||||
});
|
||||
@@ -1335,6 +1340,8 @@ class Accessibility {
|
||||
this.stayInteractiveWhenUnfocused = validated.stayInteractiveWhenUnfocused;
|
||||
if (validated.scrollToBottomOnMessageSend !== undefined)
|
||||
this.scrollToBottomOnMessageSend = validated.scrollToBottomOnMessageSend;
|
||||
if (validated.doubleClickMessageActions !== undefined)
|
||||
this.doubleClickMessageActions = validated.doubleClickMessageActions;
|
||||
if (validated.sequentialFileSend !== undefined) this.sequentialFileSend = validated.sequentialFileSend;
|
||||
if (validated.showNeko !== undefined && validated.showNeko !== this.showNeko) {
|
||||
this.showNeko = validated.showNeko;
|
||||
@@ -1438,6 +1445,7 @@ class Accessibility {
|
||||
preserveEditDraft: data.preserveEditDraft ?? this.preserveEditDraft,
|
||||
stayInteractiveWhenUnfocused: data.stayInteractiveWhenUnfocused ?? this.stayInteractiveWhenUnfocused,
|
||||
scrollToBottomOnMessageSend: data.scrollToBottomOnMessageSend ?? this.scrollToBottomOnMessageSend,
|
||||
doubleClickMessageActions: data.doubleClickMessageActions ?? this.doubleClickMessageActions,
|
||||
sequentialFileSend: data.sequentialFileSend ?? this.sequentialFileSend,
|
||||
showNeko: data.showNeko ?? this.showNeko,
|
||||
keepNekoStill: data.keepNekoStill ?? this.keepNekoStill,
|
||||
|
||||
@@ -94,6 +94,7 @@ export const ConfirmModal = observer(
|
||||
isEditing: false,
|
||||
isHighlight: false,
|
||||
disableContextMenu: true,
|
||||
disableDoubleClick: true,
|
||||
disableContextMenuTracking: true,
|
||||
contextMenuOpen: false,
|
||||
}),
|
||||
|
||||
+1
-1
@@ -137,5 +137,5 @@
|
||||
height: 1.25rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
@@ -298,7 +298,7 @@
|
||||
}
|
||||
|
||||
.channelItemAutocompleteHighlight {
|
||||
box-shadow: 0 0 0 0.125rem var(--brand-primary);
|
||||
box-shadow: 0 0 0 0.125rem var(--brand-primary-foreground);
|
||||
}
|
||||
|
||||
.categoryContent {
|
||||
|
||||
@@ -27,5 +27,5 @@
|
||||
}
|
||||
|
||||
:global(.theme-light) .dropIndicatorValid {
|
||||
background-color: var(--brand-primary);
|
||||
background-color: var(--brand-primary-foreground);
|
||||
}
|
||||
+2
-2
@@ -13,7 +13,7 @@
|
||||
}
|
||||
|
||||
:global(.theme-light) .channelBadgeSelectedIcon {
|
||||
color: #fff;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
|
||||
.favoriteItemContainer {
|
||||
@@ -85,7 +85,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 624.9375rem;
|
||||
background-color: white;
|
||||
background-color: var(--brand-primary-fill);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--brand-primary);
|
||||
|
||||
@@ -219,7 +219,7 @@
|
||||
.guildIconSelected.guildIconNoImage {
|
||||
border-radius: 30%;
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
@@ -231,7 +231,7 @@
|
||||
.guildListItem.contextMenuHover .guildIconNoImage {
|
||||
border-radius: 30%;
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@
|
||||
|
||||
.fluxerButtonIconSelected {
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
@@ -374,7 +374,7 @@
|
||||
.fluxerButtonIcon:hover,
|
||||
.fluxerButton.contextMenuHover .fluxerButtonIcon {
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,10 +782,10 @@
|
||||
:global(.theme-light) .dropIndicatorTop::before,
|
||||
:global(.theme-light) .dropIndicatorBottom::after,
|
||||
:global(.theme-light) .guildListDropZoneActive::after {
|
||||
background: var(--brand-primary);
|
||||
background: var(--brand-primary-foreground);
|
||||
}
|
||||
|
||||
:global(.theme-light) .combinePreviewGrid {
|
||||
background-color: color-mix(in srgb, var(--background-secondary) 92%, var(--brand-primary) 8%);
|
||||
outline-color: var(--brand-primary);
|
||||
outline-color: var(--brand-primary-foreground);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export const NAGBAR_TONES: Readonly<Record<NagbarToneKind, NagbarTone>> = Object
|
||||
}),
|
||||
[NagbarToneKind.DANGER]: Object.freeze({
|
||||
backgroundColor: 'var(--status-danger)',
|
||||
textColor: 'var(--text-on-brand-primary)',
|
||||
textColor: 'var(--text-on-dark)',
|
||||
}),
|
||||
[NagbarToneKind.ALERT]: Object.freeze({backgroundColor: '#ea580c', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.PREMIUM]: Object.freeze({backgroundColor: '#f97316', textColor: '#ffffff'}),
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
height: 0.125rem;
|
||||
width: 100%;
|
||||
border-radius: 624.9375rem;
|
||||
background-color: var(--brand-primary);
|
||||
background-color: var(--brand-primary-foreground);
|
||||
transition:
|
||||
transform 150ms,
|
||||
opacity 150ms;
|
||||
|
||||
+1
-1
@@ -19,5 +19,5 @@
|
||||
.iconTextSelected {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
@@ -66,7 +66,9 @@ export const DiscoveryButton = observer(() => {
|
||||
animate={{borderRadius: isHovering || isSelected ? '30%' : '50%'}}
|
||||
initial={{borderRadius: isHovering || isSelected ? '30%' : '50%'}}
|
||||
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.07, ease: 'easeOut'}}
|
||||
style={isSelected ? {backgroundColor: 'var(--brand-primary)', color: 'white'} : undefined}
|
||||
style={
|
||||
isSelected ? {backgroundColor: 'var(--brand-primary)', color: 'var(--brand-primary-fill)'} : undefined
|
||||
}
|
||||
data-flx="app.sidebar-nav.discovery-button.div--2"
|
||||
>
|
||||
<CompassIcon
|
||||
|
||||
+2
-2
@@ -269,10 +269,10 @@
|
||||
|
||||
:global(.theme-light) .dropIndicatorTop::before,
|
||||
:global(.theme-light) .dropIndicatorBottom::after {
|
||||
background: var(--brand-primary);
|
||||
background: var(--brand-primary-foreground);
|
||||
}
|
||||
|
||||
:global(.theme-light) .dropIndicatorInside .collapsedFolder,
|
||||
:global(.theme-light) .dropIndicatorInside .folderHeaderButton {
|
||||
outline-color: var(--brand-primary);
|
||||
outline-color: var(--brand-primary-foreground);
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--brand-primary);
|
||||
color: var(--brand-primary-foreground);
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
@@ -157,12 +157,12 @@
|
||||
.editIcon {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
|
||||
.editLabel {
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
|
||||
.section {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--brand-primary);
|
||||
border-radius: 624.9375rem;
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@
|
||||
.item[data-selected='true'] .fluxerIcon {
|
||||
border-radius: 30%;
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
|
||||
.fluxerIconGlyph {
|
||||
@@ -248,7 +248,7 @@
|
||||
.item[data-selected='true'] .actionIcon {
|
||||
border-radius: 30%;
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
|
||||
.actionIconGlyph {
|
||||
|
||||
@@ -270,6 +270,21 @@ describe('runtimeSnapshotFromDiscovery', () => {
|
||||
).toThrow(/appPublic\.branding\.icon_url/u);
|
||||
});
|
||||
|
||||
test('treats a black or malformed theme colour as unset and normalises the rest', () => {
|
||||
const snapshot = runtimeSnapshotFromDiscovery(parsedDocument('t.test'));
|
||||
const themeColorOf = (theme_color: unknown) =>
|
||||
requireRuntimeConfigSnapshot({
|
||||
...snapshot,
|
||||
appPublic: {...snapshot.appPublic, branding: {...snapshot.appPublic.branding, theme_color}},
|
||||
}).appPublic.branding.theme_color;
|
||||
for (const unset of ['#000000', '#000', '000000', '', 'rgb(1, 2, 3)', 'rebeccapurple', 0, null]) {
|
||||
expect(themeColorOf(unset)).toBeNull();
|
||||
}
|
||||
expect(themeColorOf('#FF0000')).toBe('#ff0000');
|
||||
expect(themeColorOf(' f00 ')).toBe('#ff0000');
|
||||
expect(themeColorOf('#000001')).toBe('#000001');
|
||||
});
|
||||
|
||||
test('carries the advertised domain migration', () => {
|
||||
const domainMigration = {
|
||||
enabled: true,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@fluxer/instance_bootstrap/src/Discovery';
|
||||
import {InstanceEndpointKind, normalizeInstanceEndpoint} from '@fluxer/instance_bootstrap/src/EndpointNormalization';
|
||||
import {CanonicalNetworkProtocol} from '@fluxer/instance_bootstrap/src/NetworkOrigin';
|
||||
import {normalizeThemeColor} from '@fluxer/instance_bootstrap/src/ThemeColor';
|
||||
import {
|
||||
AGE_POLICY_ACTIONS,
|
||||
type InstanceAgePolicy,
|
||||
@@ -413,7 +414,7 @@ function readAppPublic(value: unknown): InstanceAppPublic {
|
||||
logo_url: readNullableAbsoluteHttpUrl(branding, 'logo_url', 'appPublic.branding.logo_url'),
|
||||
wordmark_url: readNullableAbsoluteHttpUrl(branding, 'wordmark_url', 'appPublic.branding.wordmark_url'),
|
||||
favicon_url: readNullableAbsoluteHttpUrl(branding, 'favicon_url', 'appPublic.branding.favicon_url'),
|
||||
theme_color: readNullableNonEmptyString(branding, 'theme_color', 'appPublic.branding.theme_color'),
|
||||
theme_color: normalizeThemeColor(branding.theme_color),
|
||||
status_page_url: readOptionalText(branding, 'status_page_url'),
|
||||
status_page_incident_history_url: readOptionalText(branding, 'status_page_incident_history_url'),
|
||||
premium_product_name: readOptionalText(branding, 'premium_product_name') ?? DEFAULT_PREMIUM_PRODUCT_NAME,
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
--account-profile-slot-width: 14.875rem;
|
||||
--account-profile-instance-badge-size: 3rem;
|
||||
--account-profile-state-badge-size: 2.25rem;
|
||||
--account-profile-name-color: var(--text-on-brand-primary);
|
||||
--account-profile-name-color: var(--text-on-dark);
|
||||
--account-profile-domain-height: 0;
|
||||
--account-profile-domain-gap: 0;
|
||||
--account-profile-name-gap: 0.625rem;
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@
|
||||
|
||||
.noAccounts {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-on-brand-primary);
|
||||
color: var(--text-on-dark);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25rem;
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
.checkIcon {
|
||||
height: 0.625rem;
|
||||
width: 0.625rem;
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
|
||||
.code {
|
||||
|
||||
@@ -157,7 +157,7 @@ img.entityIcon {
|
||||
.themeIcon {
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
|
||||
.form {
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: 0.1875rem solid var(--background-modifier-accent);
|
||||
border-top-color: var(--brand-primary);
|
||||
border-top-color: var(--brand-primary-foreground);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@@ -53,5 +53,5 @@
|
||||
.audioIcon {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
@@ -58,11 +58,11 @@
|
||||
}
|
||||
|
||||
:global(.theme-light) .gifButton:hover {
|
||||
border-color: var(--brand-primary);
|
||||
border-color: var(--brand-primary-foreground);
|
||||
}
|
||||
|
||||
:global(.theme-light) .gifButtonSelected {
|
||||
border-color: var(--brand-primary);
|
||||
border-color: var(--brand-primary-foreground);
|
||||
}
|
||||
|
||||
.gifVideoWrapper {
|
||||
|
||||
@@ -317,5 +317,5 @@
|
||||
|
||||
:global(.theme-light) .dropIndicatorLeft::before,
|
||||
:global(.theme-light) .dropIndicatorRight::after {
|
||||
background: var(--brand-primary);
|
||||
background: var(--brand-primary-foreground);
|
||||
}
|
||||
@@ -11,7 +11,12 @@ import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHo
|
||||
import {isMediaOnlyEmbed} from '@app/features/channel/components/embeds/EmbedRenderUtils';
|
||||
import {MessageActionBar} from '@app/features/channel/components/MessageActionBar';
|
||||
import {MessageActionBottomSheet} from '@app/features/channel/components/MessageActionBottomSheet';
|
||||
import {requestDeleteMessage} from '@app/features/channel/components/MessageActionUtils';
|
||||
import {
|
||||
getMessagePermissions,
|
||||
requestDeleteMessage,
|
||||
requestMessageReply,
|
||||
startMessageEdit,
|
||||
} 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';
|
||||
@@ -100,6 +105,8 @@ const MOVEMENT_THRESHOLD = 10;
|
||||
const HIGHLIGHT_DELAY = 100;
|
||||
const SUPPRESS_POST_LONG_PRESS_CLICK_MS = 750;
|
||||
const NESTED_LONG_PRESS_OWNER_SELECTOR = '[data-long-press-owner="true"]';
|
||||
const DOUBLE_CLICK_INTERACTIVE_TARGET_SELECTOR =
|
||||
'a, button, input, textarea, select, video, audio, [role="button"], [contenteditable="true"]';
|
||||
const mobileLongPressScrollCancelHandlers = new Set<() => void>();
|
||||
|
||||
let mobileLongPressScrollListenerAttached = false;
|
||||
@@ -161,6 +168,8 @@ const getElementFromEventTarget = (target: EventTarget | null): Element | null =
|
||||
};
|
||||
const isNestedLongPressOwnerTarget = (target: EventTarget | null): boolean =>
|
||||
getElementFromEventTarget(target)?.closest(NESTED_LONG_PRESS_OWNER_SELECTOR) != null;
|
||||
const isDoubleClickInteractiveTarget = (target: EventTarget | null): boolean =>
|
||||
getElementFromEventTarget(target)?.closest(DOUBLE_CLICK_INTERACTIVE_TARGET_SELECTOR) != null;
|
||||
const normalizeAriaLabelText = (text: string): string => text.replace(/\s+/g, ' ').trim();
|
||||
const truncateAriaLabelText = (text: string): string => {
|
||||
const normalized = normalizeAriaLabelText(text);
|
||||
@@ -180,6 +189,7 @@ export type MessageBehaviorOverrides = Partial<{
|
||||
contextMenuOpen: boolean;
|
||||
disableContextMenu: boolean;
|
||||
disableContextMenuTracking: boolean;
|
||||
disableDoubleClick: boolean;
|
||||
}>;
|
||||
|
||||
interface MessageProps {
|
||||
@@ -305,6 +315,38 @@ export const Message: React.FC<MessageProps> = observer((props) => {
|
||||
},
|
||||
[message],
|
||||
);
|
||||
const handleDoubleClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
if (
|
||||
behaviorOverrides?.disableDoubleClick ||
|
||||
!Accessibility.doubleClickMessageActions ||
|
||||
mobileLayoutEnabled ||
|
||||
(previewContext && previewContext !== MessagePreviewContext.LIST_POPOUT) ||
|
||||
message.state !== MessageStates.SENT ||
|
||||
!message.isUserMessage() ||
|
||||
isEditing ||
|
||||
isDoubleClickInteractiveTarget(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const permissions = getMessagePermissions(message, channel);
|
||||
if (!permissions?.canSendMessages) return;
|
||||
if (permissions.canEditMessage && !message.messageSnapshots) {
|
||||
startMessageEdit(message);
|
||||
return;
|
||||
}
|
||||
if (!isReplying) requestMessageReply(message, {sourceChannel: channel});
|
||||
},
|
||||
[
|
||||
previewContext,
|
||||
message,
|
||||
channel,
|
||||
isEditing,
|
||||
isReplying,
|
||||
mobileLayoutEnabled,
|
||||
behaviorOverrides?.disableDoubleClick,
|
||||
],
|
||||
);
|
||||
const handleAltKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
handleAltKeyboardEvent(event, message);
|
||||
@@ -702,6 +744,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
|
||||
ref={messageRef}
|
||||
onClickCapture={handleClickCapture}
|
||||
onClick={handleAltClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onKeyDown={handleAltKeyDown}
|
||||
onFocus={handleFocusWithin}
|
||||
onBlur={handleBlurWithin}
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0 0.75rem;
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
@@ -172,7 +172,7 @@
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0 0.75rem;
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -270,6 +270,16 @@
|
||||
text-shadow: 0 0.0625rem 0.0625rem rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.gridItemCategoryEmpty .gridItemIcon,
|
||||
.gridItemCategoryEmpty .gridItemCategoryTitleText {
|
||||
color: var(--text-on-brand-primary-light);
|
||||
}
|
||||
|
||||
.gridItemFavorites .gridItemIcon,
|
||||
.gridItemFavorites .gridItemCategoryTitleText {
|
||||
color: var(--text-on-brand-primary);
|
||||
}
|
||||
|
||||
.gif {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -445,6 +455,8 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:global(.theme-light) .gridItemCategoryEmpty .gridItemCategoryTitleText,
|
||||
:global(.theme-light) .gridItemCategoryEmpty .gridItemIcon,
|
||||
:global(.theme-light) .gridItemFavorites .gridItemCategoryTitleText,
|
||||
:global(.theme-light) .gridItemFavorites .gridItemIcon {
|
||||
color: var(--text-on-brand-primary);
|
||||
@@ -459,8 +471,8 @@
|
||||
}
|
||||
|
||||
:global(.theme-light) .gridItemFocused {
|
||||
border-color: var(--brand-primary);
|
||||
box-shadow: inset 0 0 0 0.125rem var(--brand-primary);
|
||||
border-color: var(--brand-primary-foreground);
|
||||
box-shadow: inset 0 0 0 0.125rem var(--brand-primary-foreground);
|
||||
}
|
||||
|
||||
:global(.theme-light) .gridItemFavorites .gridItemBackdrop {
|
||||
|
||||
@@ -195,7 +195,7 @@
|
||||
}
|
||||
|
||||
:global(.theme-light) .progressBar {
|
||||
background-color: var(--brand-primary);
|
||||
background-color: var(--brand-primary-foreground);
|
||||
}
|
||||
|
||||
.thContent {
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
.audioIcon {
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
.audioDuration {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.125rem;
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
|
||||
.audioFilename {
|
||||
@@ -164,14 +164,14 @@
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
color: color-mix(in srgb, var(--brand-primary-fill) 90%, transparent);
|
||||
}
|
||||
|
||||
.audioBadge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
background-color: color-mix(in srgb, var(--brand-primary-shade) 20%, transparent);
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
@@ -76,6 +76,7 @@ const MessageGroupBase: React.FC<MessageGroupProps> = observer((props) => {
|
||||
? {
|
||||
...providedBehaviorOverrides,
|
||||
disableContextMenu: true,
|
||||
disableDoubleClick: true,
|
||||
}
|
||||
: providedBehaviorOverrides,
|
||||
[suppressMessageActions, providedBehaviorOverrides],
|
||||
|
||||
@@ -36,16 +36,16 @@
|
||||
}
|
||||
|
||||
.reactionMe .reactionButton {
|
||||
background-color: color-mix(in srgb, var(--brand-primary) 36%, var(--background-secondary) 64%);
|
||||
border-color: var(--brand-primary);
|
||||
color: var(--text-on-brand-primary);
|
||||
background-color: var(--brand-tint);
|
||||
border-color: var(--brand-tint-border);
|
||||
color: var(--text-on-brand-tint);
|
||||
}
|
||||
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
.reactionMe
|
||||
.reactionButton:hover {
|
||||
background-color: color-mix(in srgb, var(--brand-primary) 48%, var(--background-secondary) 52%);
|
||||
border-color: var(--brand-primary);
|
||||
background-color: var(--brand-tint-hover);
|
||||
border-color: var(--brand-tint-border);
|
||||
}
|
||||
|
||||
.reactionInner {
|
||||
@@ -80,7 +80,7 @@
|
||||
}
|
||||
|
||||
.reactionMe .countWrapper {
|
||||
color: var(--text-on-brand-primary);
|
||||
color: var(--text-on-brand-tint);
|
||||
}
|
||||
|
||||
:global(.theme-light) .reactionButton {
|
||||
@@ -96,20 +96,20 @@
|
||||
}
|
||||
|
||||
:global(.theme-light) .reactionMe .reactionButton {
|
||||
background-color: color-mix(in srgb, var(--brand-primary) 12%, var(--background-primary) 88%);
|
||||
border-color: color-mix(in srgb, var(--brand-primary) 45%, transparent 55%);
|
||||
color: var(--brand-primary);
|
||||
background-color: var(--brand-tint);
|
||||
border-color: var(--brand-tint-border);
|
||||
color: var(--text-on-brand-tint);
|
||||
}
|
||||
|
||||
:global(.theme-light:where(.window-focused, .unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
.reactionMe
|
||||
.reactionButton:hover {
|
||||
background-color: color-mix(in srgb, var(--brand-primary) 18%, var(--background-primary) 82%);
|
||||
border-color: var(--brand-primary);
|
||||
background-color: var(--brand-tint-hover);
|
||||
border-color: var(--brand-primary-foreground);
|
||||
}
|
||||
|
||||
:global(.theme-light) .reactionMe .countWrapper {
|
||||
color: var(--brand-primary);
|
||||
color: var(--text-on-brand-tint);
|
||||
}
|
||||
|
||||
.reactionContainerDisabled {
|
||||
@@ -127,8 +127,8 @@
|
||||
|
||||
.reactionMe .reactionButtonDisabled,
|
||||
.reactionMe .reactionButtonDisabled:hover {
|
||||
background-color: color-mix(in srgb, var(--brand-primary) 36%, var(--background-secondary) 64%);
|
||||
border-color: var(--brand-primary);
|
||||
background-color: var(--brand-tint);
|
||||
border-color: var(--brand-tint-border);
|
||||
}
|
||||
|
||||
:global(.theme-light) .reactionButtonDisabled,
|
||||
@@ -139,8 +139,8 @@
|
||||
|
||||
:global(.theme-light) .reactionMe .reactionButtonDisabled,
|
||||
:global(.theme-light) .reactionMe .reactionButtonDisabled:hover {
|
||||
background-color: color-mix(in srgb, var(--brand-primary) 12%, var(--background-primary) 88%);
|
||||
border-color: color-mix(in srgb, var(--brand-primary) 45%, transparent 55%);
|
||||
background-color: var(--brand-tint);
|
||||
border-color: var(--brand-tint-border);
|
||||
}
|
||||
|
||||
.reactionPickerButton {
|
||||
|
||||
@@ -110,5 +110,5 @@
|
||||
|
||||
:global(.theme-light) .progressBarIndeterminate,
|
||||
:global(.theme-light) .progressBarNormal {
|
||||
background-color: var(--brand-primary);
|
||||
background-color: var(--brand-primary-foreground);
|
||||
}
|
||||
+4
-3
@@ -170,7 +170,7 @@
|
||||
}
|
||||
|
||||
:global(.theme-light) .tabButtonActive {
|
||||
color: var(--brand-primary);
|
||||
color: var(--brand-primary-foreground);
|
||||
}
|
||||
|
||||
.tabButtonInactive {
|
||||
@@ -367,7 +367,7 @@
|
||||
}
|
||||
|
||||
.newGroupIconWhite {
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
|
||||
.newGroupContent {
|
||||
@@ -555,12 +555,13 @@
|
||||
|
||||
.quickActionButtonActive {
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.quickActionButtonActive:hover {
|
||||
background-color: var(--brand-primary-light);
|
||||
color: var(--brand-primary-light-fill);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
|
||||
:global(.theme-light) .heroIcon {
|
||||
color: var(--brand-primary);
|
||||
color: var(--brand-primary-foreground);
|
||||
}
|
||||
|
||||
.title {
|
||||
|
||||
+4
-4
@@ -90,7 +90,7 @@
|
||||
|
||||
:global(.theme-light) .dmListHeaderShortcut kbd {
|
||||
background-color: hsl(220, calc(13% * var(--saturation-factor)), 18.44%);
|
||||
color: var(--text-on-brand-primary);
|
||||
color: var(--text-on-dark);
|
||||
box-shadow: 0 0.0625rem 0 rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@
|
||||
|
||||
.clickableItemSelected .clickableItemIcon {
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
@@ -313,7 +313,7 @@
|
||||
.sendIcon {
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
|
||||
.clickableItemText {
|
||||
@@ -725,7 +725,7 @@
|
||||
|
||||
.mobileSpecialButtonSelected .mobileSpecialButtonIcon {
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
|
||||
.mobileSpecialButtonText {
|
||||
|
||||
+5
-1
@@ -403,13 +403,17 @@
|
||||
|
||||
.languageItemSelected {
|
||||
background-color: var(--brand-primary);
|
||||
color: #fff;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
|
||||
.languageItemSelected.languageItemHighlighted {
|
||||
background-color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.languageItemSelected .languageOptionCanonical {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.languageItemIndicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
+1
-1
@@ -322,7 +322,7 @@ export const collectGalleryImages = ({
|
||||
};
|
||||
export const getBorderColor = (color: number | undefined) => {
|
||||
if (color === undefined || color === 0) {
|
||||
return 'var(--brand-primary)';
|
||||
return 'var(--brand-primary-foreground)';
|
||||
}
|
||||
return ColorUtils.int2rgb(color);
|
||||
};
|
||||
@@ -149,7 +149,7 @@
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: var(--text-on-brand-primary);
|
||||
color: var(--text-on-dark);
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
transition: background-color 150ms ease;
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
color: var(--text-on-brand-primary);
|
||||
color: var(--text-on-dark);
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
--action-toolbar-item-size: 2rem;
|
||||
--action-toolbar-item-border-width: 0px;
|
||||
--action-toolbar-item-background: rgba(0, 0, 0, 0.75);
|
||||
--action-toolbar-item-color: var(--text-on-brand-primary);
|
||||
--action-toolbar-item-color: var(--text-on-dark);
|
||||
}
|
||||
|
||||
.overlayButton svg {
|
||||
@@ -25,5 +25,5 @@
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
.overlayButton.overlayButtonHover:hover
|
||||
svg {
|
||||
color: color-mix(in srgb, var(--text-on-brand-primary) 95%, transparent);
|
||||
color: color-mix(in srgb, var(--text-on-dark) 95%, transparent);
|
||||
}
|
||||
+6
-2
@@ -46,11 +46,15 @@
|
||||
inset: -0.1875rem;
|
||||
border-radius: var(--radius-full);
|
||||
border: 0.125rem solid transparent;
|
||||
border-top-color: var(--text-on-brand-primary);
|
||||
border-top-color: var(--brand-primary-foreground);
|
||||
animation: spin 0.8s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.containerActive .loadingSpinner {
|
||||
border-top-color: var(--text-on-brand-primary);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
@@ -84,7 +88,7 @@
|
||||
}
|
||||
|
||||
:global(.theme-light) .waveformBarPast {
|
||||
background-color: var(--brand-primary);
|
||||
background-color: var(--brand-primary-foreground);
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
|
||||
@@ -211,13 +211,13 @@
|
||||
|
||||
.actionButtonAccept {
|
||||
--action-toolbar-item-background: var(--friend-action-brand-background);
|
||||
--action-toolbar-item-color: color-mix(in srgb, var(--brand-primary) 76%, var(--text-primary) 24%);
|
||||
--action-toolbar-item-color: color-mix(in srgb, var(--brand-primary-foreground) 76%, var(--text-primary) 24%);
|
||||
}
|
||||
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
.actionButtonAccept:hover {
|
||||
--action-toolbar-item-background: var(--friend-action-brand-hover-background);
|
||||
--action-toolbar-item-color: color-mix(in srgb, var(--brand-primary) 82%, var(--text-primary) 18%);
|
||||
--action-toolbar-item-color: color-mix(in srgb, var(--brand-primary-foreground) 82%, var(--text-primary) 18%);
|
||||
}
|
||||
|
||||
.actionButtonIgnore {
|
||||
|
||||
+1
-1
@@ -41,5 +41,5 @@
|
||||
}
|
||||
|
||||
:global(.theme-light) .progressBar {
|
||||
background-color: var(--brand-primary);
|
||||
background-color: var(--brand-primary-foreground);
|
||||
}
|
||||
@@ -114,7 +114,7 @@
|
||||
.actionButton:hover {
|
||||
border-color: var(--brand-primary);
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
color: var(--brand-primary-fill);
|
||||
}
|
||||
|
||||
.deleteButton:hover {
|
||||
|
||||
Loaded 100 of 327 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user