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>
21 files changed,
+1853
−5
changelog/2026-07-12-report.md+11 −0db.go+200 −0ios/LChat/Views/NativeChatScreen.swift+19 −0ios/LChatCore/Sources/LChatCore/LChatAPI.swift+27 −0ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift+14 −0ios/LChatCore/Sources/LChatCore/MessageBubble.swift+28 −2ios/LChatCore/Sources/LChatCore/MessageRightClick.swift+3 −0ios/LChatCore/Sources/LChatCore/Models.swift+5 −0ios/LChatCore/Sources/LChatCore/ReportSheet.swift+277 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+17 −0ios/LChatCore/Tests/LChatCoreTests/EventVectorTests.swift+31 −0ios/LChatMac/NativeChatView.swift+24 −0ios/LChatMac/NativeFeedView.swift+13 −1main.go+248 −1reports_test.go+192 −0test/event-vectors.test.mjs+3 −0web/static/apps/admin.js+287 −0web/static/apps/chat.js+28 −0web/static/shell.js+349 −1web/static/style.css+63 −0web/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 |