eleven / messenger Public

Moderation 3/6: report to admins, moderation inbox, warn, audit log (#578) (#756)

* server+web: mute a chat, mute a person (#578)

server: muted_threads marker (pinned/hidden pattern) skipped by push
fan-out on both transports; sealed per-user prefs blob (/api/prefs, CAS)
for the muted-people set the server can't read.
web: kebab Mute chat, thread-list slashed bell + dot suppression, profile
Mute person, message-menu Mute/Unmute, muted-stub fold with per-session
reveal, SW notification gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: changelog entry for mute

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server+web: DM privacy + end conversation (#578)

server: users.dm_privacy door enforced at DM creation (admin+bot
carve-out); closed_dms state refuses msg/edit/typing on both transports,
kills queued scheduled deliveries, hides for both sides; reopen follows
the other side's door.
web: Edit-profile DM setting, DM kebab End conversation, closed banner
with Reopen replacing the composer, profile Message button says when a
door is closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios+mac: mute a chat, mute a person (#578)

Mute chat in thread menus/swipe; bell.slash replaces the unread dot;
NSE goes passive for muted threads + muted senders; muted-people set
synced via the sealed prefs blob (CAS, web-compatible shape); muted
bubbles fold to a Show stub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server+web: report to admins, moderation inbox, warn, audit log (#578)

server: reports queue metadata + per-admin sealed content blobs, 5/hour
filing brake, resolve-once semantics; audit_log appended by admin
removal, admin group-removal, level removal, warn, and resolution.
web: Report on message long-press + profile (category, note, quoted
evidence, also-mute), sealed to each admin via sealForPub; Admin app
Reports inbox (repeat signals, warn/remove/demote ladder, inline
expanders) + Audit log; warnings render with a notice framing (warn
flag pinned in the event vectors).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios+mac: DM privacy + end conversation (#578)

DM door picker in Settings, End conversation in DM menus, closed banner
with Reopen replacing the composer, dm_closed/dm_reopened WS folds,
typed dms-closed refusal on new DMs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios+mac: report to admins + warn framing (#578)

Report in message menus + profile sheet; shared ReportSheet seals the
web-shape content to each admin key (ECIES); MessagePayload.warn +
notice framing (EventVectorTests case for the admin_warning_message
vector).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* admin: expander actions keep the row — true meant refresh, wiping the expander just opened

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* admin: queue resolutions repaint the audit log too

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: live mute folds — mix muted state into the keyed-list rev

Found in the browser drive: keyedList skips rows whose rev didn't
change, so muting a person folded nothing until a reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: size the report sheet's checkboxes — the width:100% input rule shoved their labels out of the card

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server+web+sw: throttled admin notification when a report lands (#578)

One push per quiet window (15 min) however many reports arrive; each
banner carries the open count so the throttle loses nothing. Same
plaintext-operational class + transports as the changelog announcement
(Web Push + APNs), shown even while the app is open; live admin
sessions also repaint the queue on the report_filed frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: report sheet shows work while sending — form freezes, button spins

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server+web: report arrivals light the Admin thread + write a line in its stream

The throttled report_notice frame (once per burst, admins only) drives
the per-device dot + hidden-tab banner — a self-posted bubble can't
light your own dot — while one visible session writes the durable
'⚠️ A report came in' line into the Admin app's Chat stream; the
plaintext push keeps covering fully closed apps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: report success replaces the sheet with a confirmation, not a buttonless frozen form

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e5ab59ab4da18dad4e732e6f49ea1f82fc4013a7 parent b01849a
21 files changed, +1853 −5
  • changelog/2026-07-12-report.md +11 −0
  • db.go +200 −0
  • ios/LChat/Views/NativeChatScreen.swift +19 −0
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +27 −0
  • ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift +14 −0
  • ios/LChatCore/Sources/LChatCore/MessageBubble.swift +28 −2
  • ios/LChatCore/Sources/LChatCore/MessageRightClick.swift +3 −0
  • ios/LChatCore/Sources/LChatCore/Models.swift +5 −0
  • ios/LChatCore/Sources/LChatCore/ReportSheet.swift +277 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +17 −0
  • ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift +31 −0
  • ios/LChatMac/NativeChatView.swift +24 −0
  • ios/LChatMac/NativeFeedView.swift +13 −1
  • main.go +248 −1
  • reports_test.go +192 −0
  • test/event-vectors.test.mjs +3 −0
  • web/static/apps/admin.js +287 −0
  • web/static/apps/chat.js +28 −0
  • web/static/shell.js +349 −1
  • web/static/style.css +63 −0
  • web/static/sw.js +14 −0
diff --git a/changelog/2026-07-12-report.md b/changelog/2026-07-12-report.md
new file mode 100644
index 0000000..d2bc1fe
--- /dev/null
+++ b/changelog/2026-07-12-report.md
@@ -0,0 +1,11 @@
+🤖 You can now report a message or a person to your space's admins —
+long-press a message and choose Report, or use the Report button on someone's
+profile. Pick a reason, add a note if you like, and it goes straight to the
+admins, sealed so only they can read it (the server can't, and neither can
+anyone else). Admins get a Reports inbox in the Admin panel with clear next
+steps — dismiss, send a warning, remove someone from a group, an access
+level, or the space — and an audit log records every moderation action. One
+honest note: reports are one side of a story, and the inbox says so — admins
+read them and use their judgment. Admins
+also get a notification when a report comes in — throttled, so a burst of
+reports means one banner (with the waiting count), not a pile.
diff --git a/db.go b/db.go
index 09b13eb..0ec4131 100644
--- a/db.go
+++ b/db.go
@@ -9,6 +9,7 @@ import (
"encoding/base64"
"encoding/hex"
"errors"
+ "log"
"path/filepath"
"strconv"
"strings"
@@ -413,6 +414,43 @@ CREATE TABLE IF NOT EXISTS closed_dms (
closed_by TEXT NOT NULL,
created_at INTEGER NOT NULL
);
+-- reports (issue #578): the moderation queue's METADATA — who reported whom,
+-- where, and what became of it. The report's CONTENT (category, words, quoted
+-- evidence) never lands here: it travels in report_blobs, sealed to each
+-- admin's public key, unreadable by the server. Reports are testimony, not
+-- proof — the queue UI says so out loud — and the row here is the same
+-- connection-level class as thread membership.
+CREATE TABLE IF NOT EXISTS reports (
+ id TEXT PRIMARY KEY,
+ created_at INTEGER NOT NULL,
+ reporter_id TEXT NOT NULL,
+ reported_id TEXT NOT NULL,
+ thread_id TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'open',
+ action TEXT NOT NULL DEFAULT '',
+ resolved_by TEXT NOT NULL DEFAULT '',
+ resolved_at INTEGER NOT NULL DEFAULT 0
+);
+CREATE TABLE IF NOT EXISTS report_blobs (
+ report_id TEXT NOT NULL,
+ admin_id TEXT NOT NULL,
+ blob TEXT NOT NULL,
+ PRIMARY KEY (report_id, admin_id)
+);
+-- audit_log (issue #578): every moderation action — who did what to whom,
+-- when, off which report. Append-only action metadata (the roster's class),
+-- served to admins only; any quoted content stays sealed in report_blobs,
+-- never here.
+CREATE TABLE IF NOT EXISTS audit_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ts INTEGER NOT NULL,
+ actor_id TEXT NOT NULL,
+ action TEXT NOT NULL,
+ subject_id TEXT NOT NULL DEFAULT '',
+ thread_id TEXT NOT NULL DEFAULT '',
+ report_id TEXT NOT NULL DEFAULT '',
+ detail TEXT NOT NULL DEFAULT ''
+);
-- per-user read watermark: the time of the newest message that user has read in
-- a thread, so "read here" clears the unread dot on their other devices too.
-- A timestamp only — no content, and it never affects other members.
@@ -3032,6 +3070,168 @@ func (db *DB) dmClosed(threadID string) (bool, string) {
return err == nil, by
}
+// ---- reports + audit log (issue #578) ---------------------------------------
+
+// Report is a queue row: metadata + (when the caller is an admin member) the
+// content blob sealed to THAT admin's key. ReporterCount/ReportedCount are the
+// repeat signals the queue shows — how often the reporter files, how often the
+// reported member is named — because testimony is judged on patterns.
+type Report struct {
+ ID string `json:"id"`
+ CreatedAt int64 `json:"created_at"`
+ ReporterID string `json:"reporter_id"`
+ ReportedID string `json:"reported_id"`
+ ThreadID string `json:"thread_id,omitempty"`
+ Status string `json:"status"`
+ Action string `json:"action,omitempty"`
+ ResolvedBy string `json:"resolved_by,omitempty"`
+ ResolvedAt int64 `json:"resolved_at,omitempty"`
+ Blob string `json:"blob,omitempty"`
+ ReporterCount int `json:"reporter_count"`
+ ReportedCount int `json:"reported_count"`
+}
+
+// createReport stores the queue row + the per-admin sealed content blobs in
+// one transaction.
+func (db *DB) createReport(reporterID, reportedID, threadID string, blobs map[string]string) (string, error) {
+ id, err := randToken()
+ if err != nil {
+ return "", err
+ }
+ tx, err := db.sql.Begin()
+ if err != nil {
+ return "", err
+ }
+ defer tx.Rollback()
+ if _, err := tx.Exec(
+ "INSERT INTO reports(id, created_at, reporter_id, reported_id, thread_id) VALUES(?, ?, ?, ?, ?)",
+ id, time.Now().Unix(), reporterID, reportedID, threadID,
+ ); err != nil {
+ return "", err
+ }
+ for adminID, blob := range blobs {
+ if _, err := tx.Exec(
+ "INSERT INTO report_blobs(report_id, admin_id, blob) VALUES(?, ?, ?)",
+ id, adminID, blob,
+ ); err != nil {
+ return "", err
+ }
+ }
+ return id, tx.Commit()
+}
+
+// reportsSince counts a reporter's filings in a window — the content-blind
+// brake on harassment-by-reporting.
+func (db *DB) reportsSince(reporterID string, since int64) int {
+ var n int
+ db.sql.QueryRow("SELECT COUNT(*) FROM reports WHERE reporter_id = ? AND created_at >= ?", reporterID, since).Scan(&n)
+ return n
+}
+
+// listReports returns the queue (open first, newest first), each row carrying
+// the caller-admin's own sealed blob and the repeat-signal counts.
+func (db *DB) listReports(forAdminID string) ([]Report, error) {
+ rows, err := db.sql.Query(`
+ SELECT r.id, r.created_at, r.reporter_id, r.reported_id, r.thread_id,
+ r.status, r.action, r.resolved_by, r.resolved_at,
+ COALESCE((SELECT blob FROM report_blobs b WHERE b.report_id = r.id AND b.admin_id = ?), ''),
+ (SELECT COUNT(*) FROM reports x WHERE x.reporter_id = r.reporter_id),
+ (SELECT COUNT(*) FROM reports x WHERE x.reported_id = r.reported_id)
+ FROM reports r
+ ORDER BY (r.status = 'open') DESC, r.created_at DESC
+ LIMIT 500`, forAdminID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []Report{}
+ for rows.Next() {
+ var r Report
+ if err := rows.Scan(&r.ID, &r.CreatedAt, &r.ReporterID, &r.ReportedID, &r.ThreadID,
+ &r.Status, &r.Action, &r.ResolvedBy, &r.ResolvedAt, &r.Blob,
+ &r.ReporterCount, &r.ReportedCount); err != nil {
+ return nil, err
+ }
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+
+// openReportCount is what the report notification says — "N waiting" is
+// queue metadata the server already holds, never content.
+func (db *DB) openReportCount() int {
+ var n int
+ db.sql.QueryRow("SELECT COUNT(*) FROM reports WHERE status = 'open'").Scan(&n)
+ return n
+}
+
+// resolveReport records the outcome. Reports whether the row was still open
+// (a second resolver loses, keeping one outcome per report).
+func (db *DB) resolveReport(id, action, resolvedBy string) (bool, error) {
+ res, err := db.sql.Exec(
+ "UPDATE reports SET status = 'resolved', action = ?, resolved_by = ?, resolved_at = ? WHERE id = ? AND status = 'open'",
+ action, resolvedBy, time.Now().Unix(), id,
+ )
+ if err != nil {
+ return false, err
+ }
+ n, err := res.RowsAffected()
+ return n > 0, err
+}
+
+// AuditEntry is one appended moderation action.
+type AuditEntry struct {
+ ID int64 `json:"id"`
+ Ts int64 `json:"ts"`
+ ActorID string `json:"actor_id"`
+ Action string `json:"action"`
+ SubjectID string `json:"subject_id,omitempty"`
+ ThreadID string `json:"thread_id,omitempty"`
+ ReportID string `json:"report_id,omitempty"`
+ Detail string `json:"detail,omitempty"`
+}
+
+// addAudit appends one moderation action. Best-effort at call sites (an audit
+// write must never fail the action it records), so it returns no error.
+func (db *DB) addAudit(actorID, action, subjectID, threadID, reportID, detail string) {
+ _, err := db.sql.Exec(
+ "INSERT INTO audit_log(ts, actor_id, action, subject_id, thread_id, report_id, detail) VALUES(?, ?, ?, ?, ?, ?, ?)",
+ time.Now().Unix(), actorID, action, subjectID, threadID, reportID, detail,
+ )
+ if err != nil {
+ log.Println("audit:", err)
+ }
+}
+
+// listAudit pages the log newest-first; before=0 starts at the top.
+func (db *DB) listAudit(before int64, limit int) ([]AuditEntry, error) {
+ if limit <= 0 || limit > 200 {
+ limit = 100
+ }
+ q := "SELECT id, ts, actor_id, action, subject_id, thread_id, report_id, detail FROM audit_log"
+ args := []any{}
+ if before > 0 {
+ q += " WHERE id < ?"
+ args = append(args, before)
+ }
+ q += " ORDER BY id DESC LIMIT ?"
+ args = append(args, limit)
+ rows, err := db.sql.Query(q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []AuditEntry{}
+ for rows.Next() {
+ var e AuditEntry
+ if err := rows.Scan(&e.ID, &e.Ts, &e.ActorID, &e.Action, &e.SubjectID, &e.ThreadID, &e.ReportID, &e.Detail); err != nil {
+ return nil, err
+ }
+ out = append(out, e)
+ }
+ return out, rows.Err()
+}
+
// setThreadMuted mutes (muted=true) or unmutes (muted=false) a chat for one
// user — a per-user marker, synced across their devices via getThreads. The
// push fan-out consults it so a muted chat never buzzes any of their devices.
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 8ccf2b1..37d8b8c 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -161,6 +161,14 @@ struct NativeChatScreen: View {
// The DM partner's profile card (rename them for yourself there — #577).
@State private var headerProfile: HeaderProfileTarget?
struct HeaderProfileTarget: Identifiable { let id: String }
+ // A message's Report action (#578): the reported sender + the message as
+ // evidence, feeding the shared ReportSheet.
+ @State private var reportTarget: ReportTarget?
+ struct ReportTarget: Identifiable {
+ let uid: String
+ let evidence: ReportEvidence
+ var id: Int64 { evidence.sid }
+ }
// Whether the bottom sentinel is on screen — gates the scroll pins.
@State private var bottomVisible = false
// The composer inset's measured height — its growth re-pins the feed (see
@@ -312,6 +320,13 @@ struct NativeChatScreen: View {
}
.presentationDetents([.medium])
}
+ // Report a message to the admins (#578) — the shared sheet, with
+ // the message as evidence and this thread's client-side name.
+ .sheet(item: $reportTarget) { target in
+ ReportSheet(store: store, uid: target.uid, threadID: thread.id,
+ threadName: thread.title, evidence: target.evidence)
+ .presentationDetents([.medium, .large])
+ }
.safeAreaInset(edge: .bottom) {
// An app surface owns the whole pane, inputs included — the
// chat composer steps aside (ChatView's interface-view rule).
@@ -1000,6 +1015,10 @@ struct NativeChatScreen: View {
actions.onMutePerson = {
Task { await store.setPersonMuted(uid: uid, muted: !muted) }
}
+ // Report to the space's admins (#578) — same gate as the mute.
+ actions.onReport = {
+ reportTarget = ReportTarget(uid: uid, evidence: ReportEvidence(message: m))
+ }
}
return actions
}
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index 31a0cda..5dba26b 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -565,6 +565,33 @@ public struct LChatAPI {
return .failed
}
+ /// fileReport's outcome: .tooMany is the server's 5-reports-an-hour brake
+ /// (429) — the one refusal with its own inline copy.
+ public enum ReportResult { case ok, tooMany, failed }
+
+ /// Report a member to the space's admins (POST /api/reports, issue #578).
+ /// Each blob is the report content ECIES-sealed by THIS client to one
+ /// admin's public key — the server keeps only queue metadata (who
+ /// reported whom, where); it cannot read a report. `blobs` is
+ /// [{admin_id, blob}]; the server requires at least one and checks every
+ /// target holds the admin role.
+ public func fileReport(reportedID: String, threadID: String,
+ blobs: [[String: String]]) async -> ReportResult {
+ guard let url = URL(string: "api/reports", relativeTo: baseURL),
+ let data = try? JSONSerialization.data(withJSONObject: [
+ "token": token, "reported_id": reportedID,
+ "thread_id": threadID, "blobs": blobs,
+ ]) else { return .failed }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = data
+ guard let (_, resp) = try? await session.data(for: req),
+ let code = (resp as? HTTPURLResponse)?.statusCode else { return .failed }
+ if code == 200 { return .ok }
+ return code == 429 ? .tooMany : .failed
+ }
+
/// The caller's pinned Away flag (GET /api/me) — what the settings toggle
/// shows. nil when the fetch failed.
public func away() async -> Bool? {
diff --git a/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift b/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift
index 01597fd..86add9d 100644
--- a/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift
+++ b/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift
@@ -16,6 +16,7 @@ public struct MemberProfileSheet<AvatarView: View>: View {
@State private var editing = false
@State private var draft = ""
@State private var syncing = false
+ @State private var showReport = false
@FocusState private var draftFocused: Bool
public init(store: ThreadStore, uid: String,
@@ -62,6 +63,19 @@ public struct MemberProfileSheet<AvatarView: View>: View {
}
.buttonStyle(.borderless)
}
+
+ // Report them (#578) — the same sheet a message's Report action
+ // opens, minus the quoted message. People only (the web's
+ // !e.is_agent gate); a bot has no behaviour to adjudicate.
+ if isPerson {
+ Button("Report \(shownName.isEmpty ? "them" : shownName)") {
+ showReport = true
+ }
+ .buttonStyle(.borderless)
+ }
+ }
+ .sheet(isPresented: $showReport) {
+ ReportSheet(store: store, uid: uid)
}
.padding(24)
.padding(.top, 8) // room for the Done control above the avatar
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
index 5cb8e8b..03baaf0 100644
--- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
+++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
@@ -65,6 +65,9 @@ public struct MessageActions {
public var senderMuted = false
public var senderLabel = ""
public var onMutePerson: (() -> Void)?
+ /// Report the sender to the space's admins (#578) — same gate as the
+ /// mute: others' uid-carrying messages only. Opens the report sheet.
+ public var onReport: (() -> Void)?
public init(canEdit: Bool = false,
onReply: (() -> Void)? = nil, onEdit: (() -> Void)? = nil,
@@ -72,7 +75,8 @@ public struct MessageActions {
onReact: ((String) -> Void)? = nil,
timesShown: Bool = false, onToggleTimes: (() -> Void)? = nil,
senderMuted: Bool = false, senderLabel: String = "",
- onMutePerson: (() -> Void)? = nil) {
+ onMutePerson: (() -> Void)? = nil,
+ onReport: (() -> Void)? = nil) {
self.canEdit = canEdit
self.onReply = onReply
self.onEdit = onEdit
@@ -84,6 +88,7 @@ public struct MessageActions {
self.senderMuted = senderMuted
self.senderLabel = senderLabel
self.onMutePerson = onMutePerson
+ self.onReport = onReport
}
/// "Mute Alice" / "Unmute Alice" — one builder so the SwiftUI menu and the
@@ -186,6 +191,18 @@ public struct MessageBubble: View {
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
}
+ // An admin warning (#578) keeps the ordinary bubble but announces
+ // itself — a formal notice should never read like small talk
+ // (web: .warn-tag in buildMessageEl). Spoken as part of the bubble
+ // (the tag itself is hidden from VoiceOver; the bubble's label
+ // carries the notice) so the framing survives accessibility too.
+ if message.raw.warn == true {
+ Text("⚠️ A notice from an admin")
+ .font(.system(size: 12, weight: .bold))
+ .foregroundStyle(.red) // the native danger tone (web --danger)
+ .padding(.horizontal, 6)
+ .accessibilityHidden(true)
+ }
if !inReplyThread, let reply = message.reply, reply.name != nil || reply.snippet != nil {
replyQuote(reply)
}
@@ -216,7 +233,13 @@ public struct MessageBubble: View {
CardView(card: card)
} else {
if !message.text.isEmpty {
- bubble
+ if message.raw.warn == true {
+ // The warn tag above is a11y-hidden; the bubble speaks
+ // the notice so the framing is never lost to VoiceOver.
+ bubble.accessibilityLabel("A notice from an admin. \(message.text)")
+ } else {
+ bubble
+ }
}
// The web's precedence (chat.js): a YouTube link gets the
// click-to-play card; any other link gets the unfurl preview.
@@ -290,6 +313,9 @@ public struct MessageBubble: View {
if let mute = actions.onMutePerson {
Button(actions.mutePersonTitle, action: mute)
}
+ if let report = actions.onReport {
+ Button("Report", action: report) // matches the web's plain label
+ }
if message.mine, let delete = actions.onDelete {
Divider() // the native idiom for a destructive action set apart
Button("Delete…", role: .destructive, action: delete)
diff --git a/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift b/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift
index 9da27d4..599c604 100644
--- a/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift
+++ b/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift
@@ -54,6 +54,9 @@ struct MessageRightClick: NSViewRepresentable {
if let mute = actions.onMutePerson {
menu.addItem(ClosureMenuItem(title: actions.mutePersonTitle, handler: mute))
}
+ if let report = actions.onReport {
+ menu.addItem(ClosureMenuItem(title: "Report", handler: report))
+ }
if mine, let delete = actions.onDelete {
menu.addItem(.separator())
let item = ClosureMenuItem(title: "Delete…", handler: delete)
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift
index daabebf..8f0dbd0 100644
--- a/ios/LChatCore/Sources/LChatCore/Models.swift
+++ b/ios/LChatCore/Sources/LChatCore/Models.swift
@@ -158,6 +158,11 @@ public struct MessagePayload: Codable, Equatable, Sendable {
// and validated at render time, so an unknown future card never fails the
// payload decode — `text` stays its mandatory plain fallback.
public var card: JSONValue?
+ // An admin warning (issue #578): an ordinary DM message whose payload
+ // carries warn:true — clients render the same bubble with a "notice from
+ // an admin" framing. Inside the ciphertext, so the server can't tell a
+ // warning from a hello. Vector: admin_warning_message in chat.json.
+ public var warn: Bool?
// Reaction event fields (type == "reaction"):
public var targetSid: Int64? // message id being reacted to
public var targetUid: String? // the reacted-to message's AUTHOR — stamped by the
diff --git a/ios/LChatCore/Sources/LChatCore/ReportSheet.swift b/ios/LChatCore/Sources/LChatCore/ReportSheet.swift
new file mode 100644
index 0000000..1165cae
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/ReportSheet.swift
@@ -0,0 +1,277 @@
+import SwiftUI
+
+// The report sheet (issue #578) — the native twin of the web's openReportSheet
+// (shell.js): a required category, the quoted message when one is reported,
+// optional words, sealed by THIS client to each admin's public key and posted
+// as opaque blobs. The server keeps only queue metadata (who reported whom,
+// where) — it cannot read a report, and the sheet says plainly that admins
+// will. Shared by iOS and Mac; presented from a message's Report action (with
+// the message as evidence) or a profile card's Report button (no evidence).
+// The admin QUEUE stays web-only, like level management — this sheet only
+// files.
+
+/// The one reported message a report carries (web: content.evidence[0]) — a
+/// client-side snapshot of what the reporter saw. Testimony, not proof;
+/// admins read it and use their judgment.
+public struct ReportEvidence: Sendable {
+ public let sid: Int64
+ public let uid: String
+ public let name: String
+ public let ts: Int64
+ public let text: String
+ public let attachments: [String]
+
+ public init(sid: Int64, uid: String, name: String, ts: Int64,
+ text: String, attachments: [String]) {
+ self.sid = sid
+ self.uid = uid
+ self.name = name
+ self.ts = ts
+ self.text = text
+ self.attachments = attachments
+ }
+
+ /// Snapshot a feed message — the exact fields the web sends (chat.js
+ /// reportMessage → shell.js's evidence entry), attachment names only.
+ public init(message m: ChatFeedModel.Message) {
+ self.init(sid: m.id,
+ uid: m.raw.uid ?? "",
+ name: m.name,
+ ts: m.raw.ts ?? 0,
+ text: m.text,
+ attachments: m.attachments.map { $0.name ?? $0.type ?? "attachment" })
+ }
+}
+
+public struct ReportSheet: View {
+ @ObservedObject var store: ThreadStore
+ let uid: String
+ /// The thread the report is about ("" for a person report from a profile
+ /// card — the web's exact split).
+ let threadID: String
+ /// The thread's client-side display name; rides INSIDE the sealed content
+ /// so admins see where without the server learning names.
+ let threadName: String
+ let evidence: ReportEvidence?
+
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var category: String? // "spam" | "harassment" | "other"
+ @State private var note = ""
+ @State private var alsoMute = false
+ @State private var sending = false
+ @State private var sent = false
+ @State private var errorText: String?
+
+ public init(store: ThreadStore, uid: String, threadID: String = "",
+ threadName: String = "", evidence: ReportEvidence? = nil) {
+ self.store = store
+ self.uid = uid
+ self.threadID = threadID
+ self.threadName = threadName
+ self.evidence = evidence
+ }
+
+ // The familiar triad, matching the web's radio chips.
+ private static let categories: [(id: String, label: String)] = [
+ ("spam", "Spam"), ("harassment", "Harassment"), ("other", "Something else"),
+ ]
+
+ private var who: String {
+ let n = store.displayName(for: uid)
+ return n.isEmpty ? "them" : n
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ Text("Report \(who)")
+ .font(.title3.weight(.semibold))
+ .accessibilityAddTraits(.isHeader)
+
+ // Category — required before Send enables (the web's rule).
+ HStack(spacing: 6) {
+ ForEach(Self.categories, id: \.id) { cat in
+ categoryChip(cat.id, cat.label)
+ }
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel("Reason")
+
+ // The quoted evidence — shown so the reporter knows exactly what
+ // travels (web's .report-quote).
+ if let ev = evidence, !ev.text.isEmpty || !ev.attachments.isEmpty {
+ (Text("\(who): ").bold()
+ + Text(ev.text.isEmpty ? ev.attachments.joined(separator: ", ") : ev.text))
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .lineLimit(4)
+ .padding(.leading, 10)
+ .overlay(alignment: .leading) {
+ RoundedRectangle(cornerRadius: 1.5)
+ .fill(.quaternary)
+ .frame(width: 3)
+ }
+ }
+
+ TextEditor(text: $note)
+ .font(.body)
+ .frame(minHeight: 64, maxHeight: 96)
+ .scrollContentBackground(.hidden)
+ .padding(6)
+ .background(RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .strokeBorder(.quaternary, lineWidth: 1))
+ .overlay(alignment: .topLeading) {
+ if note.isEmpty {
+ Text("Anything the admins should know (optional)")
+ .font(.body)
+ .foregroundStyle(.tertiary)
+ .padding(.horizontal, 11)
+ .padding(.vertical, 14)
+ .allowsHitTesting(false)
+ }
+ }
+ .onChange(of: note) { _, v in
+ if v.count > 1000 { note = String(v.prefix(1000)) } // web's maxLength
+ }
+ .accessibilityLabel("Anything the admins should know (optional)")
+
+ // Honest copy — testimony, not proof; admins see what you send.
+ Text("Your report goes to this space's admins, including the message above and what you write. Reports can't be verified automatically — admins read them and use their judgment.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+
+ // One tap to also stop seeing them (hidden once already muted).
+ if !store.mutedPeople.contains(uid) {
+ Toggle("Also mute \(who)", isOn: $alsoMute)
+ .toggleStyle(.switch)
+ }
+
+ // Status + actions — all inline, never an alert or a toast.
+ if sent {
+ Text("Sent. The admins will take it from here.")
+ .font(.callout.weight(.medium))
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.top, 2)
+ } else {
+ if let errorText {
+ Text(errorText)
+ .font(.footnote)
+ .foregroundStyle(.red)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ HStack {
+ Spacer()
+ Button("Cancel") { dismiss() }
+ .keyboardShortcut(.cancelAction)
+ Button(sending ? "Sending…" : "Send report") { send() }
+ .buttonStyle(.borderedProminent)
+ .disabled(category == nil || sending)
+ }
+ }
+ }
+ .padding(24)
+ .frame(minWidth: 320, idealWidth: 400, maxWidth: 440)
+ }
+
+ // One radio chip — capsule buttons like the web's .report-cat row, the
+ // pick carried by tint AND the checkmark (state is never color alone).
+ private func categoryChip(_ id: String, _ label: String) -> some View {
+ let on = category == id
+ return Button {
+ category = id
+ } label: {
+ HStack(spacing: 4) {
+ if on { Image(systemName: "checkmark").font(.system(size: 10, weight: .bold)) }
+ Text(label).font(.system(size: 13, weight: .medium))
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 7) // keeps the target ≥24pt tall
+ .background(Capsule().fill(on ? AnyShapeStyle(.tint.opacity(0.18))
+ : AnyShapeStyle(.primary.opacity(0.06))))
+ .overlay(Capsule().strokeBorder(on ? AnyShapeStyle(.tint)
+ : AnyShapeStyle(.quaternary), lineWidth: 1))
+ .contentShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ .accessibilityAddTraits(on ? [.isSelected] : [])
+ }
+
+ private func send() {
+ guard let category, !sending, !sent else { return }
+ sending = true
+ errorText = nil
+ Task {
+ // Seal to every admin (the server requires ≥1 blob and checks
+ // each target's role). The reported name inside the content is
+ // the PUBLISHED roster label, like the web — a report leaves this
+ // device, so my private nickname for them must not.
+ let admins = store.reportableAdmins
+ let reportedLabel = store.roster.first { $0.user_id == uid }?.label ?? ""
+ guard !admins.isEmpty,
+ let content = Self.contentData(
+ category: category,
+ text: note.trimmingCharacters(in: .whitespacesAndNewlines),
+ reportedUID: uid, reportedName: reportedLabel,
+ threadName: threadName, evidence: evidence)
+ else {
+ sending = false
+ errorText = "Couldn't send — try again"
+ return
+ }
+ var blobs: [[String: String]] = []
+ for a in admins {
+ guard let sealed = try? ECIES.seal(bytes: content,
+ recipientPubB64: a.public_key) else {
+ sending = false
+ errorText = "Couldn't send — try again"
+ return
+ }
+ blobs.append(["admin_id": a.user_id, "blob": sealed])
+ }
+ switch await store.fileReport(reportedID: uid, threadID: threadID, blobs: blobs) {
+ case .ok:
+ // Fire-and-forget like the web: the mute applies locally at
+ // once; its sealed-prefs save syncs on its own clock.
+ if alsoMute { Task { await store.setPersonMuted(uid: uid, muted: true) } }
+ sending = false
+ sent = true
+ try? await Task.sleep(nanoseconds: 1_500_000_000)
+ dismiss()
+ case .tooMany:
+ sending = false
+ errorText = "Too many reports just now — try later"
+ case .failed:
+ sending = false
+ errorText = "Couldn't send — try again"
+ }
+ }
+ }
+
+ // The sealed content — the cross-platform contract (shell.js
+ // openReportSheet builds the identical shape): {v, category, text,
+ // reported:{uid,name}, threadName, evidence?}. `evidence` is omitted
+ // entirely for a person report, like the web's undefined key.
+ static func contentData(category: String, text: String,
+ reportedUID: String, reportedName: String,
+ threadName: String, evidence: ReportEvidence?) -> Data? {
+ struct Reported: Encodable { let uid: String; let name: String }
+ struct Evidence: Encodable {
+ let sid: Int64; let uid: String; let name: String
+ let ts: Int64; let text: String; let attachments: [String]
+ }
+ struct Content: Encodable {
+ let v: Int; let category: String; let text: String
+ let reported: Reported; let threadName: String
+ let evidence: [Evidence]?
+ }
+ let ev = evidence.map {
+ [Evidence(sid: $0.sid, uid: $0.uid, name: $0.name,
+ ts: $0.ts, text: $0.text, attachments: $0.attachments)]
+ }
+ return try? JSONEncoder().encode(Content(
+ v: 1, category: category, text: text,
+ reported: Reported(uid: reportedUID, name: reportedName),
+ threadName: threadName, evidence: ev))
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index 9f635ef..32e2b65 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -681,6 +681,23 @@ public final class ThreadStore: ObservableObject {
}
}
+ // MARK: - Reports (#578)
+
+ /// Who a report can be sealed to: roster members holding the admin role,
+ /// with a public key to seal to, people only (the web's exact filter in
+ /// openReportSheet). Bots never adjudicate reports.
+ public var reportableAdmins: [RosterEntry] {
+ roster.filter { $0.role == "admin" && !$0.public_key.isEmpty && !$0.isAgent }
+ }
+
+ /// POST the sealed report blobs (ReportSheet builds + seals them; the
+ /// store just owns the API handle). `blobs` is [{admin_id, blob}].
+ public func fileReport(reportedID: String, threadID: String,
+ blobs: [[String: String]]) async -> LChatAPI.ReportResult {
+ guard let api else { return .failed }
+ return await api.fileReport(reportedID: reportedID, threadID: threadID, blobs: blobs)
+ }
+
// MARK: - Muted people (#578)
/// Mute or unmute a person, applying locally at once (the sync is
diff --git a/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift
index f23640d..c4315b9 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift
@@ -336,6 +336,37 @@ final class EventVectorTests: XCTestCase {
XCTAssertFalse(reaction.isMine)
}
+ // The admin_warning_message vector (test/event-vectors/chat.json, #578):
+ // an ordinary DM message whose payload carries warn:true. Same bubble,
+ // distinct "notice from an admin" framing; the flag rides inside the
+ // ciphertext, so the server never knows a warning from a hello.
+ func testAdminWarningMessage() throws {
+ let json = """
+ {
+ "name": "Root",
+ "text": "A report was filed about your behaviour here. Please keep this space welcoming.",
+ "ts": 1700000300000,
+ "uid": "user-admin-1",
+ "warn": true
+ }
+ """
+
+ let data = json.data(using: .utf8)!
+ let payload = try JSONDecoder().decode(MessagePayload.self, from: data)
+
+ XCTAssertEqual(payload.name, "Root")
+ XCTAssertEqual(payload.text,
+ "A report was filed about your behaviour here. Please keep this space welcoming.")
+ XCTAssertEqual(payload.uid, "user-admin-1")
+ XCTAssertTrue(payload.warn ?? false, "warn flag should decode true")
+ XCTAssertTrue(payload.isRenderableChat, "a warning renders as an ordinary chat message")
+
+ // An ordinary message decodes warn as nil — absent means not a warning.
+ let plain = try JSONDecoder().decode(
+ MessagePayload.self, from: #"{"name":"Alice","text":"hello"}"#.data(using: .utf8)!)
+ XCTAssertNil(plain.warn, "absent warn should be nil, never false-positive")
+ }
+
func testDecodingWithMissingOptionalFields() throws {
// The server may omit optional fields; decoder should handle gracefully.
let json = """
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 199b4d3..6f99002 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -204,6 +204,12 @@ struct NativeChatView: View {
source: store.avatarSource(for: e), size: 72)
}
}
+ // Report a message to the admins (#578) — the shared sheet, with the
+ // message as evidence and the thread's client-side name.
+ .sheet(item: $reportTarget) { target in
+ ReportSheet(store: store, uid: target.uid, threadID: target.thread.id,
+ threadName: target.thread.title, evidence: target.evidence)
+ }
.sheet(item: $renameSheetThread) { t in
RenameGroupSheet(store: store, thread: t)
}
@@ -549,6 +555,15 @@ struct NativeChatView: View {
// the store's sealed-prefs save.
onMutePerson: { [weak store] uid, on in
Task { await store?.setPersonMuted(uid: uid, muted: on) }
+ },
+ // Report a sender (#578) — this view owns the
+ // sheet (it holds the store that seals to the
+ // admins' keys).
+ onReport: { m in
+ guard let uid = m.raw.uid, !uid.isEmpty else { return }
+ reportTarget = ReportTarget(
+ thread: thread, uid: uid,
+ evidence: ReportEvidence(message: m))
})
.id(thread.id + "|" + thread.threadKeyB64 + "|"
+ String(UInt(bitPattern: ObjectIdentifier(model).hashValue)))
@@ -633,6 +648,15 @@ struct NativeChatView: View {
// A DM header's profile card (rename them for yourself there — #577).
@State private var headerProfile: HeaderProfileTarget?
struct HeaderProfileTarget: Identifiable { let id: String }
+ // A message's Report action (#578): reported sender + the message as
+ // evidence + the thread it happened in, feeding the shared ReportSheet.
+ @State private var reportTarget: ReportTarget?
+ struct ReportTarget: Identifiable {
+ let thread: ChatThread
+ let uid: String
+ let evidence: ReportEvidence
+ var id: Int64 { evidence.sid }
+ }
// One menu for every surface a thread shows on: rows, the rail, the header.
@ViewBuilder private func threadMenu(_ t: ChatThread) -> some View {
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 8acdeb3..38b41c9 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -106,7 +106,8 @@ struct NativeFeedView: View {
instance: SavedInstance? = nil, instanceKeyB64: String? = nil,
mentionAvatar: @escaping (String) -> AvatarSource?,
onReopenDM: (() async -> LChatAPI.ReopenDM)? = nil,
- onMutePerson: ((String, Bool) -> Void)? = nil) {
+ onMutePerson: ((String, Bool) -> Void)? = nil,
+ onReport: ((ChatFeedModel.Message) -> Void)? = nil) {
// The model arrives PARKED (NativeFeedModelCache) — folded messages and
// a live socket survive leaving the chat, so re-opening never flashes.
_model = StateObject(wrappedValue: model)
@@ -139,6 +140,7 @@ struct NativeFeedView: View {
self.instanceKeyB64 = instanceKeyB64
self.onReopenDM = onReopenDM
self.onMutePerson = onMutePerson
+ self.onReport = onReport
}
private let defaultCommand: AppDefaultCommand.Spec?
@@ -155,6 +157,10 @@ struct NativeFeedView: View {
// Mute/unmute a message's sender (#578) — the container (NativeChatView)
// routes it to ThreadStore.setPersonMuted; nil hides the menu item.
private let onMutePerson: ((String, Bool) -> Void)?
+ // Report a message's sender to the admins (#578) — the container owns the
+ // sheet (it holds the ThreadStore with the roster + keys to seal to);
+ // nil hides the menu item.
+ private let onReport: ((ChatFeedModel.Message) -> Void)?
// The companion app's start-of-history intro (chatIntro on the web),
// shown above the earliest message once all history is loaded.
@@ -1431,6 +1437,12 @@ struct NativeFeedView: View {
actions.senderLabel = model.senderName(m)
actions.onMutePerson = { onMutePerson(uid, !muted) }
}
+ // Report to the space's admins (#578) — same gate as the mute: never
+ // your own messages, and a uid to report (legacy uid-less messages
+ // name no one).
+ if let onReport, !m.mine, let uid = m.raw.uid, !uid.isEmpty {
+ actions.onReport = { onReport(m) }
+ }
return actions
}
diff --git a/main.go b/main.go
index 4138f38..b82eb31 100644
--- a/main.go
+++ b/main.go
@@ -90,6 +90,11 @@ type Server struct {
// Directory of sideloaded mini-app modules served at /static/apps-custom/
// (LCHAT_APPS_DIR); empty disables the feature. See customapps.go.
appsDir string
+ // The report-notification throttle (issue #578): one push per quiet
+ // window however many reports land. In-memory on purpose — a restart at
+ // worst re-notifies once, and the queue itself is the durable record.
+ reportPushMu sync.Mutex
+ lastReportPush time.Time
// inflight de-duplicates concurrent unfurls of the same URL.
inflight sync.Map
// active tracks the last time each device token reported being open + focused
@@ -445,6 +450,11 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("GET /api/prefs", s.getPrefs)
mux.HandleFunc("POST /api/prefs", s.postPrefs)
mux.HandleFunc("POST /api/dm-privacy", s.postDMPrivacy)
+ mux.HandleFunc("POST /api/reports", s.postReport)
+ mux.HandleFunc("GET /api/admin/reports", s.adminReports)
+ mux.HandleFunc("POST /api/admin/reports/{id}/resolve", s.resolveReportHandler)
+ mux.HandleFunc("POST /api/admin/warn", s.adminWarn)
+ mux.HandleFunc("GET /api/admin/audit", s.adminAudit)
mux.HandleFunc("POST /api/group-description", s.postGroupDescription)
mux.HandleFunc("GET /api/threads", s.getThreads)
mux.HandleFunc("POST /api/threads", s.postThread)
@@ -1752,13 +1762,17 @@ func (s *Server) postTierRemove(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusBadRequest, "bad request")
return
}
- if _, ok := s.tierAdmin(w, body.Token); !ok {
+ uid, ok := s.tierAdmin(w, body.Token)
+ if !ok {
return
}
if err := s.db.unassignTier(r.PathValue("id"), body.UserID); err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
}
+ // Taking an access level away is a moderation-grade action (the "demote"
+ // rung on the ladder, issue #578) — record it.
+ s.db.addAudit(uid, "level.removed", body.UserID, "", "", r.PathValue("id"))
s.prodTiersChanged(body.UserID)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
@@ -2644,6 +2658,12 @@ func (s *Server) postThreadRemove(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusInternalServerError, "db error")
return
}
+ // Any member can remove in a flat group; only an ADMIN doing it is a
+ // moderation action worth the audit log (issue #578) — ordinary member
+ // removals would drown the log in group housekeeping.
+ if s.db.userRole(uid) == "admin" {
+ s.db.addAudit(uid, "thread.removed", body.UserID, threadID, "", "")
+ }
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
@@ -3185,9 +3205,236 @@ func (s *Server) removeUser(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusInternalServerError, "db error")
return
}
+ // Removal is a moderation action — it lands in the audit log (issue #578).
+ s.db.addAudit(s.adminActor(r), "member.removed", r.PathValue("id"), "", "", "")
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// adminActor resolves who performed an admin action for the audit log: the
+// admin member's uid when the bearer is a device token, else "console" (the
+// break-glass admin token has no member identity).
+func (s *Server) adminActor(r *http.Request) string {
+ const p = "Bearer "
+ h := r.Header.Get("Authorization")
+ if strings.HasPrefix(h, p) {
+ if uid, ok := s.db.deviceTokenUser(strings.TrimSpace(strings.TrimPrefix(h, p))); ok {
+ return uid
+ }
+ }
+ return "console"
+}
+
+// postReport files a report (issue #578): queue metadata for the server, the
+// report's words sealed to each admin's public key by the reporter's client.
+// Reports are testimony — the server can't verify them, and the UI says so.
+func (s *Server) postReport(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ ReportedID string `json:"reported_id"`
+ ThreadID string `json:"thread_id"`
+ Blobs []struct {
+ AdminID string `json:"admin_id"`
+ Blob string `json:"blob"`
+ } `json:"blobs"`
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if !s.tokenOK(body.Token) {
+ httpError(w, http.StatusForbidden, "not a member")
+ return
+ }
+ uid, ok := s.db.deviceTokenUser(body.Token)
+ if !ok {
+ httpError(w, http.StatusForbidden, "no user for token")
+ return
+ }
+ if body.ReportedID == "" || body.ReportedID == uid || !s.db.userExists(body.ReportedID) {
+ httpError(w, http.StatusBadRequest, "bad reported_id")
+ return
+ }
+ if body.ThreadID != "" && !s.db.threadMember(body.ThreadID, uid) {
+ httpError(w, http.StatusForbidden, "not a thread member")
+ return
+ }
+ // The report-abuse brake (content-blind): filing is cheap for a person,
+ // expensive as a flood. Repeat-filer counts also surface in the queue.
+ if s.db.reportsSince(uid, time.Now().Add(-time.Hour).Unix()) >= 5 {
+ httpError(w, http.StatusTooManyRequests, "too many reports — try again later")
+ return
+ }
+ // Every blob must be addressed to a current admin, and there must be at
+ // least one — a report nobody can read helps no one.
+ if len(body.Blobs) == 0 || len(body.Blobs) > 32 {
+ httpError(w, http.StatusBadRequest, "bad blobs")
+ return
+ }
+ blobs := map[string]string{}
+ for _, b := range body.Blobs {
+ if b.Blob == "" || len(b.Blob) > 64*1024 || s.db.userRole(b.AdminID) != "admin" {
+ httpError(w, http.StatusBadRequest, "bad blobs")
+ return
+ }
+ blobs[b.AdminID] = b.Blob
+ }
+ id, err := s.db.createReport(uid, body.ReportedID, body.ThreadID, blobs)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ // Nudge admins' live clients so the queue updates without a reload.
+ admins, _ := s.db.adminUserIDs()
+ if len(admins) > 0 {
+ set := map[string]bool{}
+ for _, a := range admins {
+ set[a] = true
+ }
+ out, _ := json.Marshal(map[string]any{"type": "report_filed", "report_id": id})
+ s.hub.broadcast <- outbound{members: set, data: out}
+ }
+ // And their away devices, throttled — one push per burst, not one per report.
+ go s.pushReportNotice()
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id})
+}
+
+// adminReports lists the moderation queue. The metadata is admin-auth like the
+// rest of /api/admin; the content blob in each row is the CALLER's sealed copy
+// (only an admin member's client holds the key to open it — the break-glass
+// console token sees metadata only, by construction).
+func (s *Server) adminReports(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ actor := s.adminActor(r)
+ reports, err := s.db.listReports(actor)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"reports": reports})
+}
+
+// resolveReportHandler records a report's outcome (the action itself — remove,
+// warn, demote — runs through its own endpoint first; this closes the loop).
+func (s *Server) resolveReportHandler(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ var body struct {
+ Action string `json:"action"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ switch body.Action {
+ case "dismissed", "warned", "removed_thread", "removed_space", "demoted", "banned":
+ default:
+ httpError(w, http.StatusBadRequest, "bad action")
+ return
+ }
+ id := r.PathValue("id")
+ ok, err := s.db.resolveReport(id, body.Action, s.adminActor(r))
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ if !ok {
+ httpError(w, http.StatusConflict, "already resolved")
+ return
+ }
+ s.db.addAudit(s.adminActor(r), "report.resolved", "", "", id, body.Action)
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// adminWarn records that a warning was issued (issue #578). The warning's
+// words travel as an ordinary E2EE DM from the admin's client with a `warn`
+// flag inside the ciphertext — the server records only that the rung was used.
+func (s *Server) adminWarn(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ var body struct {
+ UserID string `json:"user_id"`
+ ReportID string `json:"report_id"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil || body.UserID == "" {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if !s.db.userExists(body.UserID) {
+ httpError(w, http.StatusBadRequest, "no such user")
+ return
+ }
+ s.db.addAudit(s.adminActor(r), "member.warned", body.UserID, "", body.ReportID, "")
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+// reportPushCooldown is the report notification's quiet window: the first
+// report of a burst notifies at once; everything else inside the window rides
+// silently (the notification's job is "go look", and each one carries the
+// current open count, so nothing is lost to the throttle).
+const reportPushCooldown = 15 * time.Minute
+
+// pushReportNotice tells the space's admins a report arrived — at most once
+// per quiet window. The words are server-composed metadata ("N waiting"),
+// never report content: the same plaintext-operational class as the changelog
+// announcement, delivered the same two ways (Web Push + APNs) and shown even
+// while the app is open (there's no in-app badge to lean on). Returns whether
+// this call was the one that notified, for the throttle test.
+func (s *Server) pushReportNotice() bool {
+ s.reportPushMu.Lock()
+ if time.Since(s.lastReportPush) < reportPushCooldown {
+ s.reportPushMu.Unlock()
+ return false
+ }
+ s.lastReportPush = time.Now()
+ s.reportPushMu.Unlock()
+
+ admins, err := s.db.adminUserIDs()
+ if err != nil || len(admins) == 0 {
+ return true // throttle consumed; nobody to tell
+ }
+ recipients := make(map[string]bool, len(admins))
+ for _, id := range admins {
+ recipients[id] = true
+ }
+ n := s.db.openReportCount()
+ title := "New report in " + s.db.getGroupName()
+ body := "A report is waiting for review in the Admin panel."
+ if n > 1 {
+ body = fmt.Sprintf("%d reports are waiting for review in the Admin panel.", n)
+ }
+ payload, _ := json.Marshal(map[string]any{"report": true, "title": title, "body": body})
+ sent := s.sendToRecipients(payload, "report", "", false, recipients)
+ sent += s.apnsAnnounce(title, body, recipients)
+ // The same once-per-burst signal to LIVE admin sessions: each open device
+ // lights the Admin thread's dot from this frame directly (a self-posted
+ // bubble couldn't — your own messages never light your dots), and one
+ // visible session writes the durable line into the Admin stream.
+ frame, _ := json.Marshal(map[string]any{"type": "report_notice", "n": n})
+ s.hub.broadcast <- outbound{members: recipients, data: frame}
+ log.Printf("reports: notified admins (%d subscription(s), %d open)", sent, n)
+ return true
+}
+
+// adminAudit pages the moderation audit log, newest first.
+func (s *Server) adminAudit(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ before, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64)
+ limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
+ entries, err := s.db.listAudit(before, limit)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
+}
+
// createDeviceInvite lets a member mint a fresh invite for their OWN account (to
// add another device), authenticated by their existing token. The browser wraps
// the key it already holds; the server stays blind. Ties to the device token's
diff --git a/reports_test.go b/reports_test.go
new file mode 100644
index 0000000..877dd39
--- /dev/null
+++ b/reports_test.go
@@ -0,0 +1,192 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+)
+
+func modAdminGet(t *testing.T, url, bearer string) (int, map[string]any) {
+ t.Helper()
+ req, _ := http.NewRequest("GET", url, nil)
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("get %s: %v", url, err)
+ }
+ defer resp.Body.Close()
+ out := map[string]any{}
+ _ = json.NewDecoder(resp.Body).Decode(&out)
+ return resp.StatusCode, out
+}
+
+func modAdminPost(t *testing.T, url, bearer, body string) (int, map[string]any) {
+ t.Helper()
+ req, _ := http.NewRequest("POST", url, strings.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("post %s: %v", url, err)
+ }
+ defer resp.Body.Close()
+ out := map[string]any{}
+ _ = json.NewDecoder(resp.Body).Decode(&out)
+ return resp.StatusCode, out
+}
+
+// A report's lifecycle: filed with per-admin sealed blobs, listed with the
+// caller's own blob + repeat counts, resolved exactly once, audited. The
+// server never holds report content — only the sealed blobs it can't open.
+func TestReportLifecycle(t *testing.T) {
+ ts, srv := newTestServer(t)
+
+ tokRep, uidRep, _ := joinedMember(t, srv, "Reporter")
+ _, uidBad, _ := joinedMember(t, srv, "Troublemaker")
+ tokAdm, uidAdm, _ := joinedMember(t, srv, "Root")
+ if err := srv.db.setUserRole(uidAdm, "admin"); err != nil {
+ t.Fatal(err)
+ }
+
+ // Blobs must be addressed to real admins.
+ if code, _ := modPost(t, ts.URL+"/api/reports", `{"token":"`+tokRep+`","reported_id":"`+uidBad+`","blobs":[{"admin_id":"`+uidRep+`","blob":"sealed"}]}`); code != 400 {
+ t.Fatalf("blob to a non-admin should be 400, got %d", code)
+ }
+ // You can't report yourself.
+ if code, _ := modPost(t, ts.URL+"/api/reports", `{"token":"`+tokRep+`","reported_id":"`+uidRep+`","blobs":[{"admin_id":"`+uidAdm+`","blob":"sealed"}]}`); code != 400 {
+ t.Fatalf("self-report should be 400, got %d", code)
+ }
+
+ code, out := modPost(t, ts.URL+"/api/reports", `{"token":"`+tokRep+`","reported_id":"`+uidBad+`","blobs":[{"admin_id":"`+uidAdm+`","blob":"sealed-to-root"}]}`)
+ if code != 200 {
+ t.Fatalf("file report: %d %v", code, out)
+ }
+ reportID := out["id"].(string)
+
+ // The admin member sees the queue with THEIR blob and the repeat counts.
+ code, got := modAdminGet(t, ts.URL+"/api/admin/reports", tokAdm)
+ if code != 200 {
+ t.Fatalf("list reports: %d", code)
+ }
+ rows := got["reports"].([]any)
+ if len(rows) != 1 {
+ t.Fatalf("want 1 report, got %d", len(rows))
+ }
+ row := rows[0].(map[string]any)
+ if row["blob"] != "sealed-to-root" || row["status"] != "open" ||
+ row["reporter_id"] != uidRep || row["reported_id"] != uidBad ||
+ row["reporter_count"].(float64) != 1 || row["reported_count"].(float64) != 1 {
+ t.Fatalf("queue row wrong: %v", row)
+ }
+
+ // Members can't read the queue.
+ if code, _ := modAdminGet(t, ts.URL+"/api/admin/reports", tokRep); code != 401 {
+ t.Fatalf("member reading the queue should be 401, got %d", code)
+ }
+
+ // Resolve once; a second resolve conflicts (one outcome per report).
+ if code, _ := modAdminPost(t, ts.URL+"/api/admin/reports/"+reportID+"/resolve", tokAdm, `{"action":"warned"}`); code != 200 {
+ t.Fatal("resolve should succeed")
+ }
+ if code, _ := modAdminPost(t, ts.URL+"/api/admin/reports/"+reportID+"/resolve", tokAdm, `{"action":"dismissed"}`); code != 409 {
+ t.Fatal("second resolve should 409")
+ }
+
+ // Warn + resolution both audited; the log pages newest-first.
+ if code, _ := modAdminPost(t, ts.URL+"/api/admin/warn", tokAdm, `{"user_id":"`+uidBad+`","report_id":"`+reportID+`"}`); code != 200 {
+ t.Fatal("warn record should succeed")
+ }
+ code, audit := modAdminGet(t, ts.URL+"/api/admin/audit", tokAdm)
+ if code != 200 {
+ t.Fatalf("audit: %d", code)
+ }
+ entries := audit["entries"].([]any)
+ if len(entries) != 2 {
+ t.Fatalf("want 2 audit entries (resolve + warn), got %d", len(entries))
+ }
+ first := entries[0].(map[string]any)
+ if first["action"] != "member.warned" || first["actor_id"] != uidAdm || first["subject_id"] != uidBad {
+ t.Fatalf("newest audit entry wrong: %v", first)
+ }
+}
+
+// The content-blind report brake: the sixth filing within an hour bounces.
+func TestReportRateCap(t *testing.T) {
+ ts, srv := newTestServer(t)
+ tokRep, _, _ := joinedMember(t, srv, "Busy")
+ _, uidBad, _ := joinedMember(t, srv, "Target")
+ _, uidAdm, _ := joinedMember(t, srv, "Root")
+ if err := srv.db.setUserRole(uidAdm, "admin"); err != nil {
+ t.Fatal(err)
+ }
+ body := `{"token":"` + tokRep + `","reported_id":"` + uidBad + `","blobs":[{"admin_id":"` + uidAdm + `","blob":"s"}]}`
+ for i := 0; i < 5; i++ {
+ if code, _ := modPost(t, ts.URL+"/api/reports", body); code != 200 {
+ t.Fatalf("report %d should pass, got %d", i+1, code)
+ }
+ }
+ if code, _ := modPost(t, ts.URL+"/api/reports", body); code != 429 {
+ t.Fatalf("sixth report in the hour should be 429, got %d", code)
+ }
+}
+
+// Admin removal and admin-performed group removals land in the audit log;
+// an ordinary member's group removal does not (housekeeping, not moderation).
+func TestModerationAuditHooks(t *testing.T) {
+ ts, srv := newTestServer(t)
+ tokAdm, uidAdm, _ := joinedMember(t, srv, "Root")
+ if err := srv.db.setUserRole(uidAdm, "admin"); err != nil {
+ t.Fatal(err)
+ }
+ tokM, uidM, _ := joinedMember(t, srv, "Member")
+ _, uidX, _ := joinedMember(t, srv, "Removee")
+
+ gid, err := srv.db.createThread("group", "", []ThreadMemberKey{
+ {UserID: uidAdm, Wrapped: "w"}, {UserID: uidM, Wrapped: "w"}, {UserID: uidX, Wrapped: "w"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Ordinary member removes someone from the group — no audit entry.
+ if code, _ := modPost(t, ts.URL+"/api/threads/"+gid+"/remove", `{"token":"`+tokM+`","user_id":"`+uidX+`"}`); code != 200 {
+ t.Fatal("member group-remove should work")
+ }
+ // Admin removes the member from the whole space — audited.
+ req, _ := http.NewRequest("POST", ts.URL+"/api/admin/users/"+uidM+"/remove", nil)
+ req.Header.Set("Authorization", "Bearer "+tokAdm)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != 200 {
+ t.Fatalf("admin remove: %d", resp.StatusCode)
+ }
+ entries, err := srv.db.listAudit(0, 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 1 || entries[0].Action != "member.removed" || entries[0].SubjectID != uidM || entries[0].ActorID != uidAdm {
+ t.Fatalf("want exactly the admin removal audited, got %v", entries)
+ }
+}
+
+// The report-notification throttle: the first report of a burst notifies, the
+// rest inside the quiet window don't, and the window reopening notifies again.
+func TestReportPushThrottle(t *testing.T) {
+ _, srv := newTestServer(t)
+ if !srv.pushReportNotice() {
+ t.Fatal("the first report of a burst should notify")
+ }
+ if srv.pushReportNotice() {
+ t.Fatal("a report inside the quiet window must stay silent")
+ }
+ srv.reportPushMu.Lock()
+ srv.lastReportPush = time.Now().Add(-reportPushCooldown - time.Minute)
+ srv.reportPushMu.Unlock()
+ if !srv.pushReportNotice() {
+ t.Fatal("after the window, the next report notifies again")
+ }
+}
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs
index 85eacf0..970b4ea 100644
--- a/test/event-vectors.test.mjs
+++ b/test/event-vectors.test.mjs
@@ -21,6 +21,7 @@ function testChatMsgFold(state, event, meta) {
if (event.replyTo) raw.replyTo = { ...event.replyTo };
if (Array.isArray(event.actions) && event.actions.length) raw.actions = event.actions;
if (event.action) raw.action = true;
+ if (event.warn) raw.warn = true;
const msgs = [
...state.msgs,
{
@@ -34,6 +35,7 @@ function testChatMsgFold(state, event, meta) {
reply: event.replyTo ? { id: event.replyTo.id || 0, name: event.replyTo.name || "", snippet: event.replyTo.snippet || "" } : null,
actions: raw.actions || [],
action: !!event.action,
+ warn: !!event.warn,
reactions: [],
edited: false,
raw,
@@ -193,6 +195,7 @@ allVectors.chat.forEach((vector) => {
assert.strictEqual(msg.uid, vector.expected_fold_event.uid || "", "uid should match");
assert.strictEqual(msg.ts, vector.expected_fold_event.ts, "ts should match (ms)");
assert.strictEqual(msg.action, vector.expected_fold_event.action || false, "action flag should match");
+ assert.strictEqual(msg.warn, vector.expected_fold_event.warn || false, "warn flag should match");
// Attachments
if (vector.expected_fold_event.attachments) {
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js
index 3f6f084..adb2028 100644
--- a/web/static/apps/admin.js
+++ b/web/static/apps/admin.js
@@ -106,12 +106,26 @@ export const app = defineApp({
// First-run baseline: mark everything current as seen, posting nothing.
"admin.baseline": (s, e) =>
Array.isArray(e.ids) ? { seen: [...new Set([...(s.seen || []), ...e.ids.filter((x) => typeof x === "string" && x)])] } : s,
+ // A report-arrival line (issue #578): the bubble in the Chat view IS the
+ // record — shell.js posts one per server-throttled burst; no state folds.
+ "admin.report": (s) => s,
},
+ // The Admin thread's dot lights for report lines even though your own
+ // client wrote them (the shell's live path skips own messages; the
+ // load-time path consults this hook, so the dot survives a reload too).
+ unread: (e) => e.type === "admin.report",
+
// A posted note reads as a message from Admin; the baseline is silent
// housekeeping. This hook is also the companion-view opt-in (see apps.js).
chatMessage(e) {
if (e.type === "admin.note" && e.text) return e.text;
+ if (e.type === "admin.report") {
+ const n = Number(e.n) || 1;
+ return n > 1
+ ? `⚠️ A report came in — ${n} are waiting in Reports.`
+ : "⚠️ A report came in — it's waiting in Reports.";
+ }
return null;
},
@@ -873,6 +887,279 @@ export const app = defineApp({
};
paintLevels();
+ // ---- Reports (issue #578) --------------------------------------------------
+ // The moderation inbox. Reports are testimony: the row shows who says what
+ // about whom (decrypted from the blob sealed to THIS admin) plus the
+ // repeat signals; the actions are the ladder — dismiss, warn, remove from
+ // the group, remove an access level, remove from the space.
+ const repSec = section("Reports");
+ const repDesc = document.createElement("p");
+ repDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px";
+ repDesc.textContent =
+ "What members flag lands here. Reports can't be verified automatically — they're one side of a story, so weigh the patterns: how often the reporter files, how often the person is named. You only see conversations you're in; members surface the rest.";
+ repSec.appendChild(repDesc);
+ const repList = document.createElement("div");
+ repSec.appendChild(repList);
+ const repStatus = statusLine(repSec);
+
+ const fmtWhen = (ts) => new Date(ts * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
+
+ // Queue actions append audit entries, so resolving a report repaints the
+ // Audit section too (bound once that section exists below).
+ let repaintAudit = () => {};
+
+ const buildReportBox = (rep, refresh) => {
+ const box = document.createElement("div");
+ box.className = "admin-report" + (rep.status === "open" ? "" : " resolved");
+ const head = document.createElement("div");
+ head.className = "admin-report-head";
+ const title = document.createElement("strong");
+ const cat = rep.content && rep.content.category ? rep.content.category : "report";
+ title.textContent = `${rep.reported_name} — ${cat}`;
+ head.appendChild(title);
+ const when = document.createElement("span");
+ when.className = "admin-report-when";
+ when.textContent = fmtWhen(rep.created_at);
+ head.appendChild(when);
+ box.appendChild(head);
+
+ const byline = document.createElement("div");
+ byline.className = "admin-report-byline";
+ const repeat =
+ (rep.reported_count > 1 ? ` · named in ${rep.reported_count} reports` : "") +
+ (rep.reporter_count > 1 ? ` · reporter has filed ${rep.reporter_count}` : "");
+ byline.textContent =
+ `Reported by ${rep.reporter_name}` + (rep.thread_name ? ` in ${rep.thread_name}` : "") + repeat;
+ box.appendChild(byline);
+
+ if (rep.content) {
+ if (rep.content.text) {
+ const words = document.createElement("p");
+ words.className = "admin-report-words";
+ words.textContent = rep.content.text;
+ box.appendChild(words);
+ }
+ for (const ev of rep.content.evidence || []) {
+ const q = document.createElement("blockquote");
+ q.className = "report-quote";
+ const qwho = document.createElement("strong");
+ qwho.textContent = (ev.name || rep.reported_name) + ": ";
+ q.appendChild(qwho);
+ q.appendChild(document.createTextNode(ev.text || (ev.attachments || []).join(", ")));
+ box.appendChild(q);
+ }
+ } else if (rep.status === "open") {
+ const na = document.createElement("p");
+ na.className = "admin-report-words";
+ na.style.fontStyle = "italic";
+ na.textContent = "Filed before you became an admin — its contents are sealed to the admins of that moment.";
+ box.appendChild(na);
+ }
+
+ if (rep.status !== "open") {
+ const done = document.createElement("div");
+ done.className = "admin-report-byline";
+ done.textContent = `Resolved (${rep.action || "handled"})${rep.resolved_name ? ` by ${rep.resolved_name}` : ""}`;
+ box.appendChild(done);
+ return box;
+ }
+
+ // The action ladder. Each performs its act, then closes the report with
+ // the matching outcome so the queue and audit log tell one story.
+ const acts = document.createElement("div");
+ acts.className = "admin-report-actions";
+ // run() resolves to: true — the deed is done, repaint the queue;
+ // "keep" — nothing resolved (a cancelled confirm, or an expander now
+ // waiting for input), leave the row exactly as it is; false — failure.
+ const actBtn = (label, danger, run) => {
+ const b = document.createElement("button");
+ b.className = danger ? "danger" : "secondary";
+ b.textContent = label;
+ b.onclick = async () => {
+ for (const x of acts.querySelectorAll("button")) x.disabled = true;
+ const ok = await run();
+ if (ok === "keep") {
+ for (const x of acts.querySelectorAll("button")) x.disabled = false;
+ return;
+ }
+ if (!ok) {
+ for (const x of acts.querySelectorAll("button")) x.disabled = false;
+ flash(repStatus, "Couldn't do that — try again.");
+ return;
+ }
+ refresh();
+ };
+ acts.appendChild(b);
+ return b;
+ };
+ // Inline expander under the buttons — details render in place (never a
+ // floating dialog over content someone's reading).
+ const detail = document.createElement("div");
+ detail.className = "admin-report-detail";
+ const openDetail = (build) => {
+ detail.textContent = "";
+ build(detail);
+ };
+ actBtn("Dismiss", false, () => bridge.resolveReport(rep.id, "dismissed"));
+ actBtn("Warn…", false, async () => {
+ openDetail((d) => {
+ const ta = document.createElement("textarea");
+ ta.rows = 3;
+ ta.value =
+ "A report was filed about your behaviour here. Please keep this space welcoming — continued reports can mean removal.";
+ ta.setAttribute("aria-label", `Warning to ${rep.reported_name}`);
+ const send = document.createElement("button");
+ send.textContent = `Send warning to ${rep.reported_name}`;
+ send.onclick = async () => {
+ send.disabled = true;
+ if (!(await bridge.warnUser(rep.reported_id, ta.value.trim(), rep.id)) ||
+ !(await bridge.resolveReport(rep.id, "warned"))) {
+ send.disabled = false;
+ flash(repStatus, "Couldn't send the warning — try again.");
+ return;
+ }
+ refresh();
+ };
+ d.append(ta, send);
+ ta.focus();
+ });
+ return "keep"; // the expander takes it from here; report stays open
+ });
+ if (rep.thread_id && !rep.reported_gone) {
+ actBtn("Remove from group", true, async () => {
+ if (!confirm(`Remove ${rep.reported_name} from ${rep.thread_name || "that group"}?`)) return "keep";
+ if (!(await bridge.removeFromThread(rep.thread_id, rep.reported_id))) return false;
+ return bridge.resolveReport(rep.id, "removed_thread");
+ });
+ }
+ if (!rep.reported_gone) {
+ // The demote rung, only when they hold a level to lose (tiers tie-in).
+ bridge.memberLevels(rep.reported_id).then((levels) => {
+ if (!levels || !levels.length) return;
+ actBtn(`Remove access level…`, true, async () => {
+ openDetail((d) => {
+ const sel = document.createElement("select");
+ sel.setAttribute("aria-label", "Access level to remove");
+ for (const l of levels) {
+ const o = document.createElement("option");
+ o.value = l.id;
+ o.textContent = l.name;
+ sel.appendChild(o);
+ }
+ const go = document.createElement("button");
+ go.className = "danger";
+ go.textContent = `Remove level from ${rep.reported_name}`;
+ go.onclick = async () => {
+ go.disabled = true;
+ if (!(await bridge.removeFromLevel(sel.value, rep.reported_id)) ||
+ !(await bridge.resolveReport(rep.id, "demoted"))) {
+ go.disabled = false;
+ flash(repStatus, "Couldn't remove the level — try again.");
+ return;
+ }
+ refresh();
+ };
+ d.append(sel, go);
+ });
+ return "keep"; // expander flow
+ });
+ });
+ actBtn("Remove from space", true, async () => {
+ if (
+ !confirm(
+ `Remove ${rep.reported_name} from this space? Their messages and access are deleted. This can't be undone.`,
+ )
+ )
+ return "keep";
+ if (!(await bridge.removeFromSpace(rep.reported_id))) return false;
+ return bridge.resolveReport(rep.id, "removed_space");
+ });
+ }
+ box.appendChild(acts);
+ box.appendChild(detail);
+ return box;
+ };
+
+ const paintReports = async () => {
+ const reports = await bridge.reports();
+ repList.textContent = "";
+ if (reports === null) {
+ flash(repStatus, "Couldn't load reports.");
+ return;
+ }
+ if (!reports.length) {
+ const empty = document.createElement("p");
+ empty.style.cssText = "font-size:13px;color:var(--muted);margin:0";
+ empty.textContent = "No reports. Quiet is good.";
+ repList.appendChild(empty);
+ return;
+ }
+ for (const rep of reports)
+ repList.appendChild(
+ buildReportBox(rep, () => {
+ paintReports();
+ repaintAudit();
+ }),
+ );
+ };
+ paintReports();
+
+ // ---- Audit log (issue #578) -------------------------------------------------
+ // Every moderation action, append-only: who did what to whom, when, off
+ // which report. Actions only — any quoted words stay sealed to admin keys.
+ const audSec = section("Audit log");
+ const audList = document.createElement("div");
+ audSec.appendChild(audList);
+ const audMoreRow = document.createElement("div");
+ audSec.appendChild(audMoreRow);
+ const AUD_VERBS = {
+ "member.removed": "removed __S__ from the space",
+ "member.warned": "warned __S__",
+ "thread.removed": "removed __S__ from a group",
+ "level.removed": "removed an access level from __S__",
+ "report.resolved": "resolved a report",
+ "member.banned": "banned __S__",
+ "invites.paused": "paused member invites",
+ "invites.resumed": "resumed member invites",
+ };
+ let audBefore = 0;
+ const paintAudit = async (append) => {
+ const entries = await bridge.audit(append ? audBefore : 0);
+ if (!append) audList.textContent = "";
+ if (entries === null) return;
+ if (!entries.length && !append) {
+ const empty = document.createElement("p");
+ empty.style.cssText = "font-size:13px;color:var(--muted);margin:0";
+ empty.textContent = "Nothing yet — moderation actions will be recorded here.";
+ audList.appendChild(empty);
+ audMoreRow.textContent = "";
+ return;
+ }
+ for (const en of entries) {
+ const row = document.createElement("div");
+ row.className = "admin-audit-row";
+ const what = (AUD_VERBS[en.action] || en.action).replace("__S__", en.subject_name || "a member");
+ const line = document.createElement("span");
+ line.textContent = `${en.actor_name} ${what}` + (en.detail && en.action === "report.resolved" ? ` (${en.detail})` : "");
+ const when = document.createElement("span");
+ when.className = "admin-report-when";
+ when.textContent = fmtWhen(en.ts);
+ row.append(line, when);
+ audList.appendChild(row);
+ audBefore = en.id;
+ }
+ audMoreRow.textContent = "";
+ if (entries.length >= 100) {
+ const more = document.createElement("button");
+ more.className = "secondary";
+ more.textContent = "Show older";
+ more.onclick = () => paintAudit(true);
+ audMoreRow.appendChild(more);
+ }
+ };
+ paintAudit(false);
+ repaintAudit = () => paintAudit(false);
+
// ---- Updates ---------------------------------------------------------------
const updSec = section("Updates");
const updDesc = document.createElement("p");
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index ce75fad..ac5f57a 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -451,6 +451,7 @@ export const app = defineApp({
if (e.card && typeof e.card === "object") raw.card = e.card; // bot-authored card — validated at render (cards.js)
if (typeof e.key === "string" && e.key) raw.key = e.key; // scripted-line dedup key (one-Eleven welcome, shell.js)
if (e.action) raw.action = true; // keeps a /me line rendering as an action line if you edit it
+ if (e.warn) raw.warn = true; // an admin warning keeps its framing through edits too
const msgs = [
...s.msgs,
{
@@ -465,6 +466,10 @@ export const app = defineApp({
actions: raw.actions || [],
card: raw.card || null,
action: !!e.action, // an app-thread /me-style action line, not a bubble
+ // An admin warning (issue #578): same bubble, distinct system framing.
+ // It rides inside the ciphertext — the server can't tell a warning
+ // from a "hello"; it only audits that one was issued.
+ warn: !!e.warn,
reactions: [],
edited: false,
raw,
@@ -955,6 +960,16 @@ function renderItem(m, ctx) {
const content = document.createElement("div");
content.className = "content";
+ // An admin warning (issue #578) keeps the ordinary bubble but announces
+ // itself — a formal notice should never read like small talk.
+ if (m.warn) {
+ wrap.classList.add("warn");
+ const w = document.createElement("div");
+ w.className = "warn-tag";
+ w.textContent = "⚠️ A notice from an admin";
+ content.appendChild(w);
+ }
+
if (m.reply && (m.reply.name || m.reply.snippet)) {
const q = document.createElement("button");
q.type = "button";
@@ -1326,6 +1341,11 @@ function openMessagePicker(m, ctx, anchor) {
host.setPersonMuted(m.uid, !isMuted);
});
}
+ // Report to the space's admins (issue #578) — the host owns the sheet (it
+ // holds the roster + keys to seal the report to admin keys).
+ if (!mine && m.uid && host && host.reportMessage) {
+ action(icon("report"), "Report", () => host.reportMessage(ctx.threadId, m));
+ }
for (const act of messageActionsFor({ text: m.text, from: m.name })) {
const b = document.createElement("button");
b.className = "picker-action";
@@ -1438,6 +1458,14 @@ function sheetActions(m, ctx) {
run: () => host.setPersonMuted(m.uid, !isMuted),
});
}
+ // Report to the space's admins (issue #578) — same gate as the picker.
+ if (!mine && m.uid && host && host.reportMessage) {
+ acts.push({
+ label: "Report",
+ icon: icon("report"),
+ run: () => host.reportMessage(ctx.threadId, m),
+ });
+ }
return acts;
}
diff --git a/web/static/shell.js b/web/static/shell.js
index 6ddcddf..3bdaeda 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -771,6 +771,12 @@ const ICON_MUTE =
'<path d="M6.26 6.26A5.86 5.86 0 0 0 6 8c0 7-3 9-3 9h14"></path>' +
'<path d="M18 8a6 6 0 0 0-9.33-5"></path>' +
'<line x1="1" y1="1" x2="23" y2="23"></line></svg>';
+// A flag, for reporting a message or a person to the space's admins.
+const ICON_REPORT =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path>' +
+ '<line x1="4" y1="22" x2="4" y2="15"></line></svg>';
// Overlapping pages, for the "Copy" action in the mobile long-press menu.
const ICON_COPY =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' +
@@ -1803,6 +1809,171 @@ function setPersonMuted(uid, on) {
renderActiveSurfaces(); // fold/unfold their bubbles in the open thread
}
+// ---- Report to admins (issue #578) ------------------------------------------
+// The report sheet: category + optional words + the quoted message, sealed by
+// THIS client to each admin's public key and posted as opaque blobs. The
+// server keeps only queue metadata (who reported whom, where) — it cannot
+// read a report, and the sheet says plainly that admins will.
+function openReportSheet({ uid, threadId, message }) {
+ const e = roster.find((x) => x.user_id === uid);
+ if (!e) return;
+ const who = displayNameFor(nicknames[uid], e.label) || "this member";
+ const backdrop = modal();
+ const card = backdrop.querySelector(".sheet");
+ const h = document.createElement("h2");
+ h.textContent = `Report ${who}`;
+ card.appendChild(h);
+
+ // Category — the familiar triad, as radio chips.
+ const catWrap = document.createElement("div");
+ catWrap.className = "report-cats";
+ catWrap.setAttribute("role", "radiogroup");
+ catWrap.setAttribute("aria-label", "Reason");
+ let category = "";
+ const cats = [
+ ["spam", "Spam"],
+ ["harassment", "Harassment"],
+ ["other", "Something else"],
+ ];
+ for (const [val, label] of cats) {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "report-cat";
+ b.textContent = label;
+ b.setAttribute("role", "radio");
+ b.setAttribute("aria-checked", "false");
+ b.onclick = () => {
+ category = val;
+ for (const x of catWrap.children) {
+ x.classList.toggle("on", x === b);
+ x.setAttribute("aria-checked", x === b ? "true" : "false");
+ }
+ send.disabled = false;
+ };
+ catWrap.appendChild(b);
+ }
+ card.appendChild(catWrap);
+
+ // The quoted evidence — shown so the reporter knows exactly what travels.
+ if (message && (message.text || (message.attachments || []).length)) {
+ const q = document.createElement("blockquote");
+ q.className = "report-quote";
+ const qwho = document.createElement("strong");
+ qwho.textContent = who + ": ";
+ q.appendChild(qwho);
+ q.appendChild(
+ document.createTextNode(message.text || (message.attachments || []).map((a) => a.name || "attachment").join(", ")),
+ );
+ card.appendChild(q);
+ }
+
+ const ta = document.createElement("textarea");
+ ta.rows = 3;
+ ta.maxLength = 1000;
+ ta.placeholder = "Anything the admins should know (optional)";
+ card.appendChild(ta);
+
+ // Honest copy — testimony, not proof; admins see what you send.
+ const note = document.createElement("div");
+ note.className = "field-note";
+ note.textContent =
+ "Your report goes to this space's admins, including the message above and what you write. Reports can't be verified automatically — admins read them and use their judgment.";
+ card.appendChild(note);
+
+ // One tap to also stop seeing them.
+ const muteRow = document.createElement("label");
+ muteRow.className = "report-mute-row";
+ const muteCb = document.createElement("input");
+ muteCb.type = "checkbox";
+ muteRow.appendChild(muteCb);
+ muteRow.appendChild(document.createTextNode(` Also mute ${who}`));
+ if (!mutedPeople.has(uid)) card.appendChild(muteRow);
+
+ const foot = document.createElement("div");
+ foot.className = "row";
+ foot.style.cssText = "justify-content:flex-end;gap:8px;margin-top:14px";
+ const cancel = document.createElement("button");
+ cancel.className = "secondary";
+ cancel.textContent = "Cancel";
+ cancel.onclick = () => backdrop.remove();
+ const send = document.createElement("button");
+ send.textContent = "Send report";
+ send.disabled = true; // until a category is picked
+ send.onclick = async () => {
+ // Sealing to every admin key + the POST takes a beat — freeze the whole
+ // form and spin on the button so the pause reads as work, not a hang.
+ const controls = [send, cancel, ta, muteCb, ...catWrap.querySelectorAll("button")];
+ const freeze = (on) => {
+ for (const c of controls) c.disabled = on;
+ };
+ freeze(true);
+ send.textContent = "";
+ const spin = document.createElement("span");
+ spin.className = "btn-spinner";
+ spin.setAttribute("aria-hidden", "true");
+ send.append(spin, document.createTextNode(" Sending…"));
+ send.setAttribute("aria-busy", "true");
+ const unfreeze = (label) => {
+ freeze(false);
+ send.removeAttribute("aria-busy");
+ send.textContent = label;
+ };
+ try {
+ const admins = roster.filter((x) => x.role === "admin" && x.public_key && !x.is_agent);
+ if (!admins.length) throw new Error("no admins");
+ const content = {
+ v: 1,
+ category,
+ text: ta.value.trim(),
+ reported: { uid, name: e.label || "" },
+ threadName: threadId ? threadName[threadId] || "" : "",
+ };
+ if (message) {
+ content.evidence = [
+ {
+ sid: message.sid || 0,
+ uid: message.uid || "",
+ name: message.name || "",
+ ts: message.ts || 0,
+ text: message.text || "",
+ attachments: (message.attachments || []).map((a) => a.name || a.type || "attachment"),
+ },
+ ];
+ }
+ const blobs = [];
+ for (const a of admins) {
+ blobs.push({ admin_id: a.user_id, blob: await sealForPub(a.public_key, content) });
+ }
+ const r = await fetch("/api/reports", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, reported_id: uid, thread_id: threadId || "", blobs }),
+ });
+ if (r.status === 429) {
+ unfreeze("Too many reports just now — try later");
+ send.disabled = true; // the brake decides when, not another click
+ return;
+ }
+ if (!r.ok) throw new Error("server");
+ if (muteCb.checked) setPersonMuted(uid, true);
+ // Success replaces the WHOLE sheet — a frozen form with its buttons
+ // gone reads as broken, not done. role=status so AT announces it.
+ card.textContent = "";
+ const done = document.createElement("div");
+ done.className = "report-done";
+ done.setAttribute("role", "status");
+ done.textContent = "✓ Report sent — the admins will take it from here.";
+ card.appendChild(done);
+ setTimeout(() => backdrop.remove(), 1400);
+ } catch {
+ unfreeze("Couldn't send — try again");
+ }
+ };
+ foot.appendChild(cancel);
+ foot.appendChild(send);
+ card.appendChild(foot);
+}
+
function rosterLabel(userId) {
const e = roster.find((x) => x.user_id === userId);
return displayNameFor(nicknames[userId], e && e.label) || "Someone";
@@ -4570,7 +4741,7 @@ setChatAppHost({
}
return null;
},
- icons: { reply: ICON_REPLY, edit: ICON_EDIT, react: ICON_REACT, trash: ICON_TRASH, x: ICON_X, play: ICON_PLAY, copy: ICON_COPY, mute: ICON_MUTE },
+ icons: { reply: ICON_REPLY, edit: ICON_EDIT, react: ICON_REACT, trash: ICON_TRASH, x: ICON_X, play: ICON_PLAY, copy: ICON_COPY, mute: ICON_MUTE, report: ICON_REPORT },
// Helper-proposed one-tap action chips ("Switch to dark" …): whitelisted,
// labelled, and run by the host — the app just mounts the returned row.
actionChips: (actions) => {
@@ -4595,6 +4766,9 @@ setChatAppHost({
// messages into a quiet stub and offers Mute/Unmute in the message menu.
mutedPerson: (uid) => mutedPeople.has(uid),
setPersonMuted: (uid, on) => setPersonMuted(uid, on),
+ // Report a message (issue #578): the sheet lives in the shell — it holds
+ // the roster and the keys that seal the report to each admin.
+ reportMessage: (threadId, m) => openReportSheet({ uid: m.uid, threadId, message: m }),
// Closed DMs (issue #578): the app swaps its composer for the closed banner
// and offers Reopen; the server owns the state, the shell mirrors it.
dmClosed: (threadId) => !!dmClosed[threadId],
@@ -5148,6 +5322,129 @@ setSetupBridge({
if (me) paintAvatar(me, session.name, () => avatarFor(myUserId));
await publishMyLabel();
},
+ // ---- Moderation (issue #578): report queue + audit log ----
+ // The Admin app is pure UI; everything key- or name-shaped happens here.
+ // Each queue row's content blob is sealed to THIS admin's key — openSealed
+ // with our private key is the only place it ever becomes words.
+ reports: async () => {
+ try {
+ const r = await fetch("/api/admin/reports", { headers: adminAuth() });
+ if (!r.ok) return null;
+ const { reports } = await r.json();
+ for (const rep of reports) {
+ rep.reporter_name = rosterLabel(rep.reporter_id) || "A former member";
+ rep.reported_name = rosterLabel(rep.reported_id) || "A former member";
+ rep.resolved_name = rep.resolved_by ? rosterLabel(rep.resolved_by) || "an admin" : "";
+ rep.thread_name = rep.thread_id ? threadName[rep.thread_id] || "" : "";
+ rep.reported_gone = !roster.some((x) => x.user_id === rep.reported_id);
+ if (rep.blob && session.userPriv) {
+ try {
+ rep.content = await openSealed(rep.blob, session.userPriv);
+ } catch {
+ rep.content = null; // filed before I was admin — not sealed to me
+ }
+ }
+ delete rep.blob;
+ }
+ return reports;
+ } catch {
+ return null;
+ }
+ },
+ resolveReport: async (id, action) => {
+ try {
+ const r = await fetch(`/api/admin/reports/${encodeURIComponent(id)}/resolve`, {
+ method: "POST",
+ headers: adminAuth(),
+ body: JSON.stringify({ action }),
+ });
+ return r.ok;
+ } catch {
+ return false;
+ }
+ },
+ audit: async (before) => {
+ try {
+ const r = await fetch(`/api/admin/audit${before ? `?before=${encodeURIComponent(before)}` : ""}`, {
+ headers: adminAuth(),
+ });
+ if (!r.ok) return null;
+ const { entries } = await r.json();
+ for (const e of entries) {
+ e.actor_name = e.actor_id === "console" ? "Admin console" : rosterLabel(e.actor_id) || "A former admin";
+ e.subject_name = e.subject_id ? rosterLabel(e.subject_id) || "a former member" : "";
+ }
+ return entries;
+ } catch {
+ return null;
+ }
+ },
+ // Warn: the words travel as an ordinary E2EE DM from THIS admin with a
+ // `warn` flag inside the ciphertext; the server records only that the rung
+ // was used (the audit entry) — never what was said.
+ warnUser: async (uid, text, reportId) => {
+ try {
+ const dm = existingDM(uid);
+ const tid = dm ? dm.id : await createThread([uid], { group: false });
+ if (!threadKeys[tid]) await loadThreads();
+ const key = threadKeys[tid];
+ if (!key || !ws || ws.readyState !== WebSocket.OPEN) return false;
+ const payload = { name: session.name || "", uid: myUserId, ts: Date.now(), text, warn: true };
+ const blob = await encrypt(key, payload);
+ ws.send(JSON.stringify({ type: "msg", thread_id: tid, blob }));
+ const r = await fetch("/api/admin/warn", {
+ method: "POST",
+ headers: adminAuth(),
+ body: JSON.stringify({ user_id: uid, report_id: reportId || "" }),
+ });
+ return r.ok;
+ } catch {
+ return false;
+ }
+ },
+ removeFromSpace: async (uid) => {
+ try {
+ const r = await fetch(`/api/admin/users/${encodeURIComponent(uid)}/remove`, {
+ method: "POST",
+ headers: adminAuth(),
+ });
+ if (r.ok) await loadRoster();
+ return r.ok;
+ } catch {
+ return false;
+ }
+ },
+ removeFromThread: async (threadId, uid) => {
+ try {
+ const r = await fetch(`/api/threads/${encodeURIComponent(threadId)}/remove`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, user_id: uid }),
+ });
+ return r.ok;
+ } catch {
+ return false;
+ }
+ },
+ // The demote rung (the access-levels tie-in): which levels the member
+ // holds, and taking one away. Level names are policy every member already
+ // sees via /api/tiers — no new disclosure.
+ memberLevels: async (uid) => {
+ await loadTiers();
+ return tiers.filter((tt) => (tt.members || []).includes(uid)).map((tt) => ({ id: tt.id, name: tt.name }));
+ },
+ removeFromLevel: async (tierId, uid) => {
+ try {
+ const r = await fetch(`/api/tiers/${encodeURIComponent(tierId)}/remove`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, user_id: uid }),
+ });
+ return r.ok;
+ } catch {
+ return false;
+ }
+ },
setGroupName: (name) => saveGroupSettings({ name }),
setContactEmail: (email) => saveGroupSettings({ admin_email: email }),
// The admin panel saves name + contact together (one Save for the section);
@@ -7586,6 +7883,45 @@ function connectWS() {
refreshAvatars();
return;
}
+ if (msg.type === "report_filed") {
+ // A report just landed (admins' connections only get this frame). If
+ // the Admin app is on screen its queue repaints; push covers the rest.
+ if (isAdmin()) renderActiveSurfaces();
+ return;
+ }
+ if (msg.type === "report_notice") {
+ // The server's once-per-burst report signal (admins only, throttled
+ // server-side). The FRAME drives the dot and the hidden-tab banner —
+ // a self-posted bubble can't light your own dot — and one visible
+ // session writes the durable line into the Admin stream. Fully closed
+ // apps are covered by the plaintext push instead.
+ const adminTid = Object.keys(threadApp).find((id) => threadApp[id] === "admin");
+ if (adminTid) {
+ unread[adminTid] = true;
+ renderThreadList();
+ }
+ const n = Number(msg.n) || 1;
+ const body = n > 1 ? `${n} reports are waiting for review.` : "A report is waiting for review.";
+ if (
+ typeof Notification !== "undefined" &&
+ Notification.permission === "granted" &&
+ document.visibilityState !== "visible"
+ ) {
+ try {
+ new Notification("New report", { body, tag: "lchat-app-admin" });
+ } catch {}
+ }
+ // Two visible devices of the same admin could both write the line — a
+ // rare, benign duplicate in a personal stream; the dot logic above is
+ // per-device and immune.
+ if (adminTid && document.visibilityState === "visible" && threadKeys[adminTid] && ws && ws.readyState === WebSocket.OPEN) {
+ const payload = { type: "admin.report", n, name: session.name || "", uid: myUserId, ts: Date.now() };
+ encrypt(threadKeys[adminTid], payload)
+ .then((blob) => ws.send(JSON.stringify({ type: "msg", thread_id: adminTid, blob })))
+ .catch(() => {});
+ }
+ return;
+ }
if (msg.type === "dm_closed" || msg.type === "dm_reopened") {
// Either party ended (or revived) this DM. An open pane swaps between
// composer and closed banner live; the list updates its hidden state.
@@ -8524,6 +8860,18 @@ function viewProfile(uid) {
paintMute();
};
modRow.appendChild(muteBtn);
+ // Report them (issue #578) — the same sheet a message long-press opens,
+ // minus the quoted message.
+ if (!e.is_agent) {
+ const reportBtn = document.createElement("button");
+ reportBtn.className = "secondary";
+ reportBtn.textContent = `Report ${displayNameFor(nicknames[uid], e.label) || "them"}`;
+ reportBtn.onclick = () => {
+ backdrop.remove();
+ openReportSheet({ uid });
+ };
+ modRow.appendChild(reportBtn);
+ }
card.appendChild(modRow);
}
}
diff --git a/web/static/style.css b/web/static/style.css
index 1a273ee..1ee3439 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -2205,6 +2205,69 @@ input.filter-check:focus-visible { outline: 2px solid var(--accent); outline-off
}
/* Small helper line under a settings field (e.g. the DM-privacy select). */
.field-note { margin-top: 6px; font-size: 12px; color: var(--muted); }
+/* Destructive button — the moderation ladder's remove/ban rungs. */
+button.danger { background: transparent; border: 1px solid var(--danger); color: var(--danger); }
+/* Report sheet (issue #578): category chips + quoted evidence. */
+/* The send button's in-flight spinner: the join loader's ring at button
+ scale, inheriting the button's text color so it reads on the filled bg. */
+.btn-spinner {
+ display: inline-block;
+ width: 14px;
+ height: 14px;
+ vertical-align: -2px;
+ border-radius: 50%;
+ border: 2px solid color-mix(in srgb, currentColor 30%, transparent);
+ border-top-color: currentColor;
+ animation: spin 0.7s linear infinite;
+}
+@media (prefers-reduced-motion: reduce) {
+ .btn-spinner { animation: none; opacity: 0.5; }
+}
+.report-cats { display: flex; gap: 8px; margin: 10px 0; flex-wrap: wrap; }
+.report-cat {
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--ink);
+ border-radius: 999px;
+ padding: 6px 14px;
+ font-size: 13px;
+}
+.report-cat.on { border-color: var(--accent); color: var(--accent); font-weight: 600; }
+.report-quote {
+ margin: 10px 0;
+ padding: 8px 12px;
+ border-left: 3px solid var(--border);
+ font-size: 13px;
+ color: var(--muted);
+ overflow-wrap: anywhere;
+}
+.report-mute-row { display: flex; align-items: center; gap: 6px; font-size: 13px; margin-top: 10px; cursor: pointer; }
+/* The sent confirmation that replaces the report form. */
+.report-done { text-align: center; padding: 30px 10px; font-size: 15px; }
+/* Escape the app-wide `input { width:100% }` rule (the #761 fix's pattern) —
+ a full-width checkbox becomes the whole flex row and shoves its label text
+ clean out of the sheet. */
+.report-mute-row input { width: 18px; height: 18px; flex: none; margin: 0; }
+/* An admin warning's framing on the bubble (issue #578). */
+.warn-tag { font-size: 12px; font-weight: 700; color: var(--danger); margin-bottom: 4px; }
+/* Admin app: report queue + audit log (issue #578). */
+.admin-report { border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 10px 0; }
+.admin-report.resolved { opacity: 0.65; }
+.admin-report-head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; }
+.admin-report-when { font-size: 11px; color: var(--muted); white-space: nowrap; }
+.admin-report-byline { font-size: 12px; color: var(--muted); margin-top: 2px; }
+.admin-report-words { font-size: 13px; margin: 8px 0 0; overflow-wrap: anywhere; }
+.admin-report-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
+.admin-report-detail { margin-top: 8px; display: flex; flex-direction: column; gap: 8px; }
+.admin-audit-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ font-size: 13px;
+ padding: 6px 0;
+ border-bottom: 1px solid var(--border);
+}
+.admin-audit-row:last-child { border-bottom: none; }
.unread-dot { width: 8px; height: 8px; border-radius: 50%; background: transparent; flex: none; }
.unread-dot.on { background: var(--accent); }
/* Mobile list treatment (à la Messages/WhatsApp): rows run edge-to-edge, the active
diff --git a/web/static/sw.js b/web/static/sw.js
index b211724..78f8970 100644
--- a/web/static/sw.js
+++ b/web/static/sw.js
@@ -146,6 +146,20 @@ async function handlePush(event) {
data: {},
});
return;
+ } else if (data.report) {
+ // A report notification for an admin (issue #578): server-composed
+ // metadata ("N waiting"), never report content — the changelog banner's
+ // class, so like it, shown even while the app is open. Its own tag +
+ // renotify: a fresh burst replaces the old banner instead of stacking.
+ await self.registration.showNotification(String(data.title || "New report"), {
+ body: String(data.body || "Open the Admin panel to review."),
+ icon: "/static/icon-192.png",
+ badge: "/static/icon-192.png",
+ tag: "lchat-report",
+ renotify: true,
+ data: {},
+ });
+ return;
} else {
// File the row into the local message store first (#457 phase 2) — even
// when the notification is suppressed below, an idempotent put costs