Moderation 4/6: block a person (#578) (#757)
* 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> * server+web: block a person (#578) server: blocks table (connection metadata); enforced at DM create + reopen (both directions, admin/bot carve-out so notices land), group adds both ways, push fan-out (incl. scheduled); blocking closes any existing DM via the closed-DM machinery. Unblock restores possibility, never the conversation. web: profile Block/Unblock with honest copy, report sheet 'Also block', Message button hidden for blocked people, block implies mute. 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> * ios+mac: block a person (#578) Profile Block/Unblock (destructive, honest footnotes), report sheet 'Also block', blockedPeople synced in refresh, block implies mute + mirrors the server's DM close. 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> * web: freeze the Also-block toggle with the rest of the sending report form * 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> * push.go: true union of the seam — main's id/at payload + the stack's muted/blocking drops * re-graft #807's db.go/main.go/shell.js hunks lost to a stale superset assumption (main moved mid-fold) ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
11 files changed,
+575
−11
blocks_test.go+123 −0changelog/2026-07-12-block.md+7 −0db.go+73 −0ios/LChatCore/Sources/LChatCore/LChatAPI.swift+46 −0ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift+38 −0ios/LChatCore/Sources/LChatCore/ReportSheet.swift+14 −3ios/LChatCore/Sources/LChatCore/ThreadStore.swift+37 −0main.go+115 −2miniapps.go+5 −2push.go+18 −0web/static/shell.js+99 −4
diff --git a/blocks_test.go b/blocks_test.go| new file mode 100644 |
| index 0000000..a63ccbd |
| --- /dev/null |
| +++ b/blocks_test.go |
| @@ -0,0 +1,123 @@ |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// Block = no new contact, server-enforced: no fresh DM either way, no group |
| +// adds either way, any existing DM closes at once, pushes from the blocked |
| +// stop, and unblocking restores possibility (not the conversation). |
| +func TestBlock(t *testing.T) { |
| + ts, srv := newTestServer(t) |
| + |
| + tokA, uidA, _ := joinedMember(t, srv, "Alice") |
| + tokB, uidB, _ := joinedMember(t, srv, "Bob") |
| + |
| + // An existing DM, open and working. |
| + code, out := modPost(t, ts.URL+"/api/threads", dmBody(tokA, uidA, uidB)) |
| + if code != 200 { |
| + t.Fatalf("create dm: %d", code) |
| + } |
| + tid := out["thread_id"].(string) |
| + |
| + // Alice blocks Bob. |
| + if code, _ := modPost(t, ts.URL+"/api/blocks", `{"token":"`+tokA+`","user_id":"`+uidB+`"}`); code != 200 { |
| + t.Fatal("block should succeed") |
| + } |
| + // Her list shows him. |
| + resp, err := http.Get(ts.URL + "/api/blocks?token=" + tokA) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var got struct { |
| + Blocked []string `json:"blocked"` |
| + } |
| + _ = json.NewDecoder(resp.Body).Decode(&got) |
| + resp.Body.Close() |
| + if len(got.Blocked) != 1 || got.Blocked[0] != uidB { |
| + t.Fatalf("blocks list: %v", got.Blocked) |
| + } |
| + |
| + // The DM closed with the block. |
| + if closed, by := srv.db.dmClosed(tid); !closed || by != uidA { |
| + t.Fatalf("block should close the DM, got closed=%v by=%q", closed, by) |
| + } |
| + if code, _ := modPost(t, ts.URL+"/api/messages?token="+tokB, `{"thread_id":"`+tid+`","blob":"hi"}`); code != 403 { |
| + t.Fatal("messages into the blocked DM should bounce") |
| + } |
| + // Bob can't reopen it, and can't open a "new" DM either (dedupe returns |
| + // the existing one, which stays closed — so contact stays cut). |
| + if code, _ := modPost(t, ts.URL+"/api/threads/"+tid+"/reopen-dm", `{"token":"`+tokB+`"}`); code != 403 { |
| + t.Fatal("blocked party reopening should be 403") |
| + } |
| + // Alice can't reopen through her own block either — unblock first. |
| + if code, _ := modPost(t, ts.URL+"/api/threads/"+tid+"/reopen-dm", `{"token":"`+tokA+`"}`); code != 403 { |
| + t.Fatal("blocker reopening without unblocking should be 403") |
| + } |
| + |
| + // Neither can add the other to a group. |
| + gidA, err := srv.db.createThread("group", "", []ThreadMemberKey{{UserID: uidA, Wrapped: "w"}}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if code, _ := modPost(t, ts.URL+"/api/threads/"+gidA+"/members", `{"token":"`+tokA+`","user_id":"`+uidB+`","wrapped":"w"}`); code != 403 { |
| + t.Fatal("blocker adding blocked to a group should be 403") |
| + } |
| + gidB, err := srv.db.createThread("group", "", []ThreadMemberKey{{UserID: uidB, Wrapped: "w"}}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if code, _ := modPost(t, ts.URL+"/api/threads/"+gidB+"/members", `{"token":"`+tokB+`","user_id":"`+uidA+`","wrapped":"w"}`); code != 403 { |
| + t.Fatal("blocked adding blocker to a group should be 403") |
| + } |
| + |
| + // Push fan-out: Alice (who blocks Bob) drops out of Bob's recipient sets. |
| + set := map[string]bool{uidA: true} |
| + srv.dropBlockingRecipients(uidB, set) |
| + if set[uidA] { |
| + t.Fatal("a blocker should never be pushed the blocked member's messages") |
| + } |
| + |
| + // Admin still passes (a reported member can't dodge a warning by blocking). |
| + tokAdm, uidAdm, _ := joinedMember(t, srv, "Root") |
| + if err := srv.db.setUserRole(uidAdm, "admin"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if code, _ := modPost(t, ts.URL+"/api/blocks", `{"token":"`+tokB+`","user_id":"`+uidAdm+`"}`); code != 200 { |
| + t.Fatal("blocking the admin records fine") |
| + } |
| + if code, _ := modPost(t, ts.URL+"/api/threads", dmBody(tokAdm, uidAdm, uidB)); code != 200 { |
| + t.Fatal("the admin's DM passes a block (formal notices must land)") |
| + } |
| + |
| + // Unblock: contact becomes possible again, but nothing reopens by itself. |
| + if code2, _ := modDelete(t, ts.URL+"/api/blocks/"+uidB, `{"token":"`+tokA+`"}`); code2 != 200 { |
| + t.Fatal("unblock should succeed") |
| + } |
| + if closed, _ := srv.db.dmClosed(tid); !closed { |
| + t.Fatal("unblocking must not silently reopen the conversation") |
| + } |
| + if code, _ := modPost(t, ts.URL+"/api/threads/"+tid+"/reopen-dm", `{"token":"`+tokA+`"}`); code != 200 { |
| + t.Fatal("after unblocking, reopening is a normal ask again") |
| + } |
| +} |
| + |
| +func modDelete(t *testing.T, url, body string) (int, map[string]any) { |
| + t.Helper() |
| + req, err := http.NewRequest("DELETE", url, strings.NewReader(body)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Content-Type", "application/json") |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("delete %s: %v", url, err) |
| + } |
| + defer resp.Body.Close() |
| + out := map[string]any{} |
| + _ = json.NewDecoder(resp.Body).Decode(&out) |
| + return resp.StatusCode, out |
| +} |
diff --git a/changelog/2026-07-12-block.md b/changelog/2026-07-12-block.md| new file mode 100644 |
| index 0000000..51fdf3f |
| --- /dev/null |
| +++ b/changelog/2026-07-12-block.md |
| @@ -0,0 +1,7 @@ |
| +🤖 You can now block someone. Blocking mutes them, ends any direct message |
| +between you, and stops new contact both ways — they can't message you or add |
| +you to groups, and you won't be notified about anything they say. It's |
| +private: they're never told, and only your space's server keeps the (content- |
| +free) record it needs to enforce it. Find it on their profile card, or tick |
| +"Also block" when you report someone. Admins can still reach you — so a |
| +warning can't be dodged by blocking the messenger. |
diff --git a/db.go b/db.go| index 0ec71bd..55a9ab4 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -414,6 +414,18 @@ CREATE TABLE IF NOT EXISTS closed_dms ( |
| closed_by TEXT NOT NULL, |
| created_at INTEGER NOT NULL |
| ); |
| +-- blocks (issue #578): "blocker cut contact with blocked" — the server-side |
| +-- half of a block (the client side is a mute). Consulted at DM creation and |
| +-- reopen, group adds, and push fan-out; blocking also closes any existing DM. |
| +-- Connection metadata only (the dm_pairs class): the server learns that two |
| +-- members are no longer connectable, never why, and the blocked party gets |
| +-- the same generic refusal a closed DM door gives. |
| +CREATE TABLE IF NOT EXISTS blocks ( |
| + blocker_id TEXT NOT NULL, |
| + blocked_id TEXT NOT NULL, |
| + created_at INTEGER NOT NULL, |
| + PRIMARY KEY (blocker_id, blocked_id) |
| +); |
| -- 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 |
| @@ -3273,6 +3285,67 @@ func (db *DB) listAudit(before int64, limit int) ([]AuditEntry, error) { |
| return out, rows.Err() |
| } |
| +// setBlocked records (or with on=false clears) blocker→blocked. Blocking is |
| +// one-directional in the record, mutual in effect (blockedEither). |
| +func (db *DB) setBlocked(blockerID, blockedID string, on bool) error { |
| + if on { |
| + _, err := db.sql.Exec( |
| + "INSERT OR IGNORE INTO blocks(blocker_id, blocked_id, created_at) VALUES(?, ?, ?)", |
| + blockerID, blockedID, time.Now().Unix(), |
| + ) |
| + return err |
| + } |
| + _, err := db.sql.Exec("DELETE FROM blocks WHERE blocker_id = ? AND blocked_id = ?", blockerID, blockedID) |
| + return err |
| +} |
| + |
| +// blockedEither reports whether either member has blocked the other — the |
| +// question every contact gate asks (new DM, reopen, group add). |
| +func (db *DB) blockedEither(a, b string) bool { |
| + var x int |
| + return db.sql.QueryRow( |
| + "SELECT 1 FROM blocks WHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?)", |
| + a, b, b, a, |
| + ).Scan(&x) == nil |
| +} |
| + |
| +// blocksOf lists who this member has blocked (their own list; nobody else's). |
| +func (db *DB) blocksOf(userID string) ([]string, error) { |
| + rows, err := db.sql.Query("SELECT blocked_id FROM blocks WHERE blocker_id = ? ORDER BY created_at", userID) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + out := []string{} |
| + for rows.Next() { |
| + var u string |
| + if err := rows.Scan(&u); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, u) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// blockersOf returns the members who block this user — the push fan-out drops |
| +// them so a blocked member's group messages never buzz the blocker. |
| +func (db *DB) blockersOf(userID string) (map[string]bool, error) { |
| + rows, err := db.sql.Query("SELECT blocker_id FROM blocks WHERE blocked_id = ?", userID) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + out := map[string]bool{} |
| + for rows.Next() { |
| + var u string |
| + if err := rows.Scan(&u); err != nil { |
| + return nil, err |
| + } |
| + out[u] = true |
| + } |
| + 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/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift| index 5dba26b..3d83980 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift |
| @@ -659,6 +659,52 @@ public struct LChatAPI { |
| return err.contains("dms closed") ? .dmsClosed : .failed |
| } |
| + /// Who I've blocked (GET /api/blocks, issue #578) — uids. Unlike mutes |
| + /// the record lives server-side (it's what ENFORCES no new contact: |
| + /// DMs, group adds, pushes). nil when the fetch failed — distinct from |
| + /// empty, so a network blip never reads as "blocked no one". Hand-rolled |
| + /// like nicknames(): an error body decodes as an all-nil Resp otherwise. |
| + public func blocks() async -> [String]? { |
| + guard var comps = URLComponents(url: baseURL.appendingPathComponent("api/blocks"), |
| + resolvingAgainstBaseURL: false) else { return nil } |
| + comps.queryItems = [URLQueryItem(name: "token", value: token)] |
| + struct Resp: Codable { let blocked: [String]? } |
| + guard let url = comps.url, |
| + let (data, resp) = try? await session.data(from: url), |
| + (resp as? HTTPURLResponse)?.statusCode == 200, |
| + let r = try? JSONDecoder().decode(Resp.self, from: data) else { return nil } |
| + return r.blocked ?? [] |
| + } |
| + |
| + /// Block a person (POST /api/blocks): the server records no-new-contact |
| + /// both ways AND closes any existing DM (its dm_closed frame follows, |
| + /// like a manual close). Strict about the status — the profile card's |
| + /// inline "Couldn't save" hangs off a real refusal, not just a network |
| + /// error. |
| + public func block(uid: String) async -> Bool { |
| + guard let url = URL(string: "api/blocks", relativeTo: baseURL), |
| + let data = try? JSONSerialization.data(withJSONObject: ["token": token, "user_id": uid]) else { return false } |
| + 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) else { return false } |
| + return (resp as? HTTPURLResponse)?.statusCode == 200 |
| + } |
| + |
| + /// Unblock (DELETE /api/blocks/{uid}). The DM stays closed (reopening is |
| + /// its own step through reopen-dm) and any mute stays put. |
| + public func unblock(uid: String) async -> Bool { |
| + guard let url = URL(string: "api/blocks/\(uid)", relativeTo: baseURL), |
| + let data = try? JSONSerialization.data(withJSONObject: ["token": token]) else { return false } |
| + var req = URLRequest(url: url) |
| + req.httpMethod = "DELETE" |
| + req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| + req.httpBody = data |
| + guard let (_, resp) = try? await session.data(for: req) else { return false } |
| + return (resp as? HTTPURLResponse)?.statusCode == 200 |
| + } |
| + |
| /// Add a member to a thread: their copy of the thread key, wrapped to |
| /// their public key (the server stores a blob it can't open). |
| @discardableResult |
diff --git a/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift b/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift| index 86add9d..74ceb71 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift |
| @@ -17,6 +17,8 @@ public struct MemberProfileSheet<AvatarView: View>: View { |
| @State private var draft = "" |
| @State private var syncing = false |
| @State private var showReport = false |
| + @State private var blockBusy = false |
| + @State private var blockFailed = false |
| @FocusState private var draftFocused: Bool |
| public init(store: ThreadStore, uid: String, |
| @@ -73,6 +75,42 @@ public struct MemberProfileSheet<AvatarView: View>: View { |
| } |
| .buttonStyle(.borderless) |
| } |
| + |
| + // Block them (#578): mute + end the DM + server-enforced no new |
| + // contact — no new DMs, no adding each other to groups. They're |
| + // never told. People only, like the web's profile card; the |
| + // footnote says plainly what each direction does. |
| + if isPerson { |
| + let blocked = store.blockedPeople.contains(uid) |
| + Button(blocked ? "Unblock \(shownName.isEmpty ? "them" : shownName)" |
| + : "Block \(shownName.isEmpty ? "them" : shownName)", |
| + role: blocked ? nil : .destructive) { |
| + blockFailed = false |
| + blockBusy = true |
| + Task { |
| + let ok = await store.setPersonBlocked(uid: uid, on: !blocked) |
| + blockBusy = false |
| + blockFailed = !ok |
| + } |
| + } |
| + .buttonStyle(.borderless) |
| + .disabled(blockBusy) |
| + if blockFailed { |
| + // Inline, occupying layout space — never a floating toast. |
| + Text("Couldn't save — try again.") |
| + .font(.footnote) |
| + .foregroundStyle(.red) |
| + } else { |
| + Text(blocked |
| + ? "Unblocking makes contact possible again — nothing reopens by itself." |
| + : "Blocking mutes them, ends your direct messages, and stops new contact both ways. They won't be told.") |
| + .font(.footnote) |
| + .foregroundStyle(.secondary) |
| + .multilineTextAlignment(.center) |
| + .fixedSize(horizontal: false, vertical: true) |
| + .frame(maxWidth: 280) |
| + } |
| + } |
| } |
| .sheet(isPresented: $showReport) { |
| ReportSheet(store: store, uid: uid) |
diff --git a/ios/LChatCore/Sources/LChatCore/ReportSheet.swift b/ios/LChatCore/Sources/LChatCore/ReportSheet.swift| index 1165cae..bc3cf92 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ReportSheet.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ReportSheet.swift |
| @@ -59,6 +59,7 @@ public struct ReportSheet: View { |
| @State private var category: String? // "spam" | "harassment" | "other" |
| @State private var note = "" |
| @State private var alsoMute = false |
| + @State private var alsoBlock = false |
| @State private var sending = false |
| @State private var sent = false |
| @State private var errorText: String? |
| @@ -147,6 +148,14 @@ public struct ReportSheet: View { |
| .toggleStyle(.switch) |
| } |
| + // …or to also cut contact entirely (hidden once already blocked). |
| + // Checking it wins over Also-mute on send — block implies mute. |
| + if !store.blockedPeople.contains(uid) { |
| + Toggle("Also block \(who) (mutes, ends your DM, stops new contact)", |
| + isOn: $alsoBlock) |
| + .toggleStyle(.switch) |
| + } |
| + |
| // Status + actions — all inline, never an alert or a toast. |
| if sent { |
| Text("Sent. The admins will take it from here.") |
| @@ -231,9 +240,11 @@ public struct ReportSheet: View { |
| } |
| 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) } } |
| + // Fire-and-forget like the web: the mute/block applies |
| + // locally as soon as it lands; block WINS over mute (it |
| + // already implies one), the web's exact else-if. |
| + if alsoBlock { Task { await store.setPersonBlocked(uid: uid, on: true) } } |
| + else if alsoMute { Task { await store.setPersonMuted(uid: uid, muted: true) } } |
| sending = false |
| sent = true |
| try? await Task.sleep(nanoseconds: 1_500_000_000) |
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift| index 32e2b65..e9cf672 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| @@ -48,6 +48,11 @@ public final class ThreadStore: ObservableObject { |
| // for them, and the server never learns who's muted. |
| @Published public private(set) var mutedPeople: Set<String> = [] |
| private var prefsVersion: Int64 = 0 |
| + |
| + // Blocked people (#578): who I've blocked. Unlike mutes the record lives |
| + // SERVER-side — it's what enforces no new contact (DMs, group adds, |
| + // pushes) — so it's a plain uid list read on refresh, not a sealed pref. |
| + @Published public private(set) var blockedPeople: Set<String> = [] |
| // uid → true (mute) / false (unmute), pending sync — the nicknames journal |
| // shape, replayed over a fresh server read on every save. |
| private var mutedOps: [String: Bool] = [:] |
| @@ -117,6 +122,7 @@ public final class ThreadStore: ObservableObject { |
| async let nickFetchAsync = api.nicknames() |
| async let prefsFetchAsync = api.prefs() |
| async let gatesAsync = api.featureGates() |
| + async let blocksAsync = api.blocks() |
| let people = await api.roster() |
| if myUserId.isEmpty, let me = people.first(where: { $0.public_key == instance.userPub }) { |
| @@ -150,6 +156,9 @@ public final class ThreadStore: ObservableObject { |
| let gates = await gatesAsync |
| botsEnabled = gates.botsEnabled |
| appsEnabled = gates.appsEnabled |
| + // nil = fetch failed — keep the held set rather than "unblocking" |
| + // everyone over a network blip. |
| + if let blocked = await blocksAsync { blockedPeople = Set(blocked) } |
| // ALL crypto in one detached pass — the instance-key unwrap, roster |
| // labels, per-thread ECDH unwraps, previews, titles, app ids. refresh() |
| @@ -808,6 +817,34 @@ public final class ThreadStore: ObservableObject { |
| } |
| } |
| + // MARK: - Blocked people (#578) |
| + |
| + /// Block or unblock a person (the web's setPersonBlocked). The server |
| + /// records no-new-contact and, on block, closes any existing DM — we |
| + /// mirror both at once (its dm_closed frame will confirm). Block implies |
| + /// mute, so their existing messages fold away too; unblocking leaves the |
| + /// mute AND the closed DM in place — undoing each is its own choice. |
| + /// Server-first, so a refusal changes nothing locally and the profile |
| + /// card can say "Couldn't save" honestly. |
| + @discardableResult |
| + public func setPersonBlocked(uid: String, on: Bool) async -> Bool { |
| + guard !uid.isEmpty, uid != myUserId, let api else { return false } |
| + guard await (on ? api.block(uid: uid) : api.unblock(uid: uid)) else { return false } |
| + if on { |
| + blockedPeople.insert(uid) |
| + // Block implies you stop seeing them — fire-and-forget like the |
| + // web: the mute applies locally at once inside setPersonMuted; |
| + // its sealed-prefs save syncs on its own clock. |
| + Task { await setPersonMuted(uid: uid, muted: true) } |
| + if let dm = threads.first(where: { $0.kind == "dm" && $0.members.contains(uid) }) { |
| + setDMClosedLocally(dm.id, true) // the server just closed it; mirror at once |
| + } |
| + } else { |
| + blockedPeople.remove(uid) // the mute stays until they unmute — a separate choice |
| + } |
| + return true |
| + } |
| + |
| // Pinned chats first, then most-recent activity — mirrors the web list. |
| nonisolated public static func order(_ a: ChatThread, _ b: ChatThread) -> Bool { |
| (a.pinned != b.pinned) ? a.pinned : a.lastAt > b.lastAt |
diff --git a/main.go b/main.go| index c96d3b9..e6cf93a 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -453,6 +453,9 @@ 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("GET /api/blocks", s.getBlocks) |
| + mux.HandleFunc("POST /api/blocks", s.postBlock) |
| + mux.HandleFunc("DELETE /api/blocks/{id}", s.deleteBlock) |
| 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) |
| @@ -1438,6 +1441,13 @@ func (s *Server) postThreadMember(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusBadRequest, "bad member") |
| return |
| } |
| + // A block cuts indirect contact too (issue #578): neither side can pull |
| + // the other into a group. Same generic refusal as elsewhere — the add just |
| + // doesn't work, with no hint of which mechanism said no. |
| + if s.db.blockedEither(uid, body.UserID) { |
| + httpError(w, http.StatusForbidden, "can't add this member") |
| + return |
| + } |
| // Adding an existing member is a no-op, checked here on purpose: a |
| // self-serve joiner of an open group has membership but no thread_keys |
| // row, and that empty slot must not be fillable with another member's |
| @@ -2405,10 +2415,18 @@ func (s *Server) postThreadPinned(w http.ResponseWriter, r *http.Request) { |
| // onboarding speaks through Eleven's welcome DM — and the setting's UI copy |
| // says so out loud rather than implying an absolute wall. |
| func (s *Server) dmAllowed(from, to string) bool { |
| - if s.db.userDMPrivacy(to) != "nobody" { |
| + // Admins and bots pass both doors — the operator must stay reachable (a |
| + // reported member could otherwise dodge a formal warning by blocking the |
| + // admin), and onboarding speaks through Eleven's welcome DM. |
| + if s.db.userRole(from) == "admin" || s.db.isAgent(from) { |
| return true |
| } |
| - return s.db.userRole(from) == "admin" || s.db.isAgent(from) |
| + // A block cuts contact in both directions, whoever asks (issue #578) — |
| + // the blocker unblocks first if they change their mind. |
| + if s.db.blockedEither(from, to) { |
| + return false |
| + } |
| + return s.db.userDMPrivacy(to) != "nobody" |
| } |
| // postDMPrivacy sets the caller's own DM door: "" / "everyone" (anyone in the |
| @@ -2447,6 +2465,101 @@ func (s *Server) postDMPrivacy(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true, "dm_privacy": v}) |
| } |
| +// getBlocks lists who the caller has blocked — their own list only. |
| +func (s *Server) getBlocks(w http.ResponseWriter, r *http.Request) { |
| + token := r.URL.Query().Get("token") |
| + if !s.tokenOK(token) { |
| + httpError(w, http.StatusForbidden, "not a member") |
| + return |
| + } |
| + uid, ok := s.db.deviceTokenUser(token) |
| + if !ok { |
| + httpError(w, http.StatusForbidden, "no user for token") |
| + return |
| + } |
| + list, err := s.db.blocksOf(uid) |
| + if err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"blocked": list}) |
| +} |
| + |
| +// postBlock blocks a member (issue #578): records the no-new-contact rule and |
| +// ends any existing DM with them (the closed-DM machinery, so message/edit/ |
| +// typing refusals and the closed banner all follow). The blocked party is |
| +// never told — they see the same silence and generic refusals a closed DM |
| +// door produces. |
| +func (s *Server) postBlock(w http.ResponseWriter, r *http.Request) { |
| + var body struct { |
| + Token string `json:"token"` |
| + UserID string `json:"user_id"` |
| + } |
| + 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.UserID == "" || body.UserID == uid || !s.db.userExists(body.UserID) { |
| + httpError(w, http.StatusBadRequest, "bad user_id") |
| + return |
| + } |
| + if err := s.db.setBlocked(uid, body.UserID, true); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + // Block implies the conversation ends now, not at the next refusal. |
| + if tid, found, _ := s.db.dmThreadFor(uid, body.UserID); found { |
| + if closed, _ := s.db.dmClosed(tid); !closed { |
| + _ = s.db.closeDM(tid, uid) |
| + members, _ := s.db.memberUserIDs(tid) |
| + set := map[string]bool{} |
| + for _, m := range members { |
| + _ = s.db.setThreadHidden(m, tid, true) |
| + set[m] = true |
| + } |
| + out, _ := json.Marshal(map[string]any{"type": "dm_closed", "thread_id": tid}) |
| + s.hub.broadcast <- outbound{members: set, data: out} |
| + } |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| +// deleteBlock unblocks. Deliberately narrow: the DM stays closed (reopen is |
| +// its own step, through the other person's DM door) and any client-side mute |
| +// stays put — unblocking restores the possibility of contact, nothing more. |
| +func (s *Server) deleteBlock(w http.ResponseWriter, r *http.Request) { |
| + var body struct { |
| + Token string `json:"token"` |
| + } |
| + 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 err := s.db.setBlocked(uid, r.PathValue("id"), false); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| // postCloseDM ends a DM for both parties (issue #578): it drops off both |
| // lists and the server refuses new traffic into it until someone reopens it. |
| // Distinct from hiding (which quietly returns on activity) and from blocking |
diff --git a/miniapps.go b/miniapps.go| index 14f53e4..60b8095 100644 |
| --- a/miniapps.go |
| +++ b/miniapps.go |
| @@ -296,7 +296,7 @@ func (s *Server) sweepDueScheduled() int { |
| n++ |
| out, _ := json.Marshal(wsOut{Type: "msg", ThreadID: sm.ThreadID, ID: m.ID, CreatedAt: m.CreatedAt, Blob: m.Blob}) |
| s.broadcastToThread(sm.ThreadID, out) |
| - go s.pushScheduled(m.Blob, sm.ThreadID, m.ID, m.CreatedAt) |
| + go s.pushScheduled(m.Blob, sm.ThreadID, sm.AuthorID, m.ID, m.CreatedAt) |
| } |
| return n |
| } |
| @@ -305,7 +305,7 @@ func (s *Server) sweepDueScheduled() int { |
| // difference: NO sender exclusion. The creator is exactly who wants this push (a |
| // reminder that skipped its own author would be useless). skipActive stays on — |
| // an open client receives the WS frame and the app's notify hook handles it. |
| -func (s *Server) pushScheduled(blob, threadID string, id, at int64) { |
| +func (s *Server) pushScheduled(blob, threadID, authorID string, id, at int64) { |
| members, err := s.db.memberUserIDs(threadID) |
| if err != nil { |
| log.Println("schedule: push members:", err) |
| @@ -316,6 +316,9 @@ func (s *Server) pushScheduled(blob, threadID string, id, at int64) { |
| set[u] = true |
| } |
| s.dropMutedRecipients(threadID, set) // mute wins even over a reminder |
| + if authorID != "" { |
| + s.dropBlockingRecipients(authorID, set) // a blocked author never buzzes the blocker |
| + } |
| payload, _ := json.Marshal(map[string]string{"blob": blob, "thread": threadID}) |
| s.sendToRecipients(payload, "sched", "", true, set) |
| s.apnsFanOut(threadID, blob, id, at, "", true, set) |
diff --git a/push.go b/push.go| index f65f845..3d79a6f 100644 |
| --- a/push.go |
| +++ b/push.go |
| @@ -228,6 +228,11 @@ func (s *Server) pushNewMessage(blob, threadID, senderToken string, id, at int64 |
| set[u] = true |
| } |
| s.dropMutedRecipients(threadID, set) |
| + // And members who BLOCK the sender (issue #578): a blocked member's group |
| + // messages must never buzz the blocker (the client folds them away too). |
| + if senderUser, ok := s.db.deviceTokenUser(senderToken); ok { |
| + s.dropBlockingRecipients(senderUser, set) |
| + } |
| // id/at ride along so the service worker can file the row straight into |
| // the page's local message store (#457 phase 2) — opening from a |
| // notification then paints with the message already in place. Additive: |
| @@ -256,6 +261,19 @@ func (s *Server) dropMutedRecipients(threadID string, set map[string]bool) { |
| } |
| } |
| +// dropBlockingRecipients removes members who have blocked the author from a |
| +// push recipient set — block implies never hearing from them again. |
| +func (s *Server) dropBlockingRecipients(authorID string, set map[string]bool) { |
| + blockers, err := s.db.blockersOf(authorID) |
| + if err != nil { |
| + log.Println("push: blockers:", err) |
| + return |
| + } |
| + for u := range blockers { |
| + delete(set, u) |
| + } |
| +} |
| + |
| // sendTestPush delivers a generic "it works" notification to every subscriber, |
| // bypassing the message flow (and ignoring active state, so it always lands). |
| // It covers BOTH transports: Web Push (browsers) and APNs (native iOS, via the |
diff --git a/web/static/shell.js b/web/static/shell.js| index 36ac371..66c932b 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -1119,6 +1119,8 @@ async function boot() { |
| syncThreadKeysToSW(); |
| renderActiveSurfaces(); |
| }); |
| + loadBlocks(); // the profile card's Block/Unblock state |
| + |
| await Promise.all([setupInstanceKey(), loadThreads(), Promise.race([nickLoad, new Promise((res) => setTimeout(res, 3000))])]); |
| await bootstrapBranding(); // sealed instances: open the branding blob (needs the instance key for first-time B) |
| await loadTiers(); // before the re-fold: gated groups unwrap via tier keys |
| @@ -1888,6 +1890,13 @@ function openReportSheet({ uid, threadId, message }) { |
| muteRow.appendChild(muteCb); |
| muteRow.appendChild(document.createTextNode(` Also mute ${who}`)); |
| if (!mutedPeople.has(uid)) card.appendChild(muteRow); |
| + const blockRow2 = document.createElement("label"); |
| + blockRow2.className = "report-mute-row"; |
| + const blockCb = document.createElement("input"); |
| + blockCb.type = "checkbox"; |
| + blockRow2.appendChild(blockCb); |
| + blockRow2.appendChild(document.createTextNode(` Also block ${who} (mutes, ends your DM, stops new contact)`)); |
| + if (!blockedPeople.has(uid)) card.appendChild(blockRow2); |
| const foot = document.createElement("div"); |
| foot.className = "row"; |
| @@ -1902,7 +1911,7 @@ function openReportSheet({ uid, threadId, message }) { |
| 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 controls = [send, cancel, ta, muteCb, blockCb, ...catWrap.querySelectorAll("button")]; |
| const freeze = (on) => { |
| for (const c of controls) c.disabled = on; |
| }; |
| @@ -1955,7 +1964,8 @@ function openReportSheet({ uid, threadId, message }) { |
| return; |
| } |
| if (!r.ok) throw new Error("server"); |
| - if (muteCb.checked) setPersonMuted(uid, true); |
| + if (blockCb.checked) await setPersonBlocked(uid, true); |
| + else 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 = ""; |
| @@ -1974,6 +1984,55 @@ function openReportSheet({ uid, threadId, message }) { |
| card.appendChild(foot); |
| } |
| +// ---- Blocked people (issue #578) --------------------------------------------- |
| +// Who I've blocked. The record lives server-side (it's what ENFORCES no new |
| +// contact — DMs, group adds, pushes); blocking also mutes client-side so their |
| +// existing messages fold away. They're never told. |
| +let blockedPeople = new Set(); |
| + |
| +async function loadBlocks() { |
| + try { |
| + const r = await fetch(`/api/blocks?token=${encodeURIComponent(session.token)}`); |
| + if (!r.ok) return; |
| + const body = await r.json(); |
| + blockedPeople = new Set(body.blocked || []); |
| + } catch {} |
| +} |
| + |
| +async function setPersonBlocked(uid, on) { |
| + if (!uid || uid === myUserId) return false; |
| + try { |
| + const r = on |
| + ? await fetch("/api/blocks", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, user_id: uid }), |
| + }) |
| + : await fetch(`/api/blocks/${encodeURIComponent(uid)}`, { |
| + method: "DELETE", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token }), |
| + }); |
| + if (!r.ok) return false; |
| + } catch { |
| + return false; |
| + } |
| + if (on) { |
| + blockedPeople.add(uid); |
| + setPersonMuted(uid, true); // block implies you stop seeing them |
| + const dm = existingDM(uid); |
| + if (dm) { |
| + dmClosed[dm.id] = true; // the server just closed it; mirror at once |
| + hidden[dm.id] = true; |
| + } |
| + } else { |
| + blockedPeople.delete(uid); // the mute stays until they unmute — a separate choice |
| + } |
| + renderThreadList(); |
| + renderActiveSurfaces(); |
| + return true; |
| +} |
| + |
| function rosterLabel(userId) { |
| const e = roster.find((x) => x.user_id === userId); |
| return displayNameFor(nicknames[userId], e && e.label) || "Someone"; |
| @@ -8868,9 +8927,10 @@ function viewProfile(uid) { |
| } |
| // Message them — the card's action when it was reached from somewhere other |
| - // than their DM (the member sheet, a mention). Inside their DM it's noise. |
| + // than their DM (the member sheet, a mention). Inside their DM it's noise — |
| + // and on someone you've blocked it's a contradiction (Unblock is below). |
| const dm = existingDM(uid); |
| - if (uid !== myUserId && !(dm && dm.id === activeId)) { |
| + if (uid !== myUserId && !(dm && dm.id === activeId) && !blockedPeople.has(uid)) { |
| const row = document.createElement("div"); |
| row.className = "row"; |
| row.style.cssText = "justify-content:center;margin-top:14px"; |
| @@ -8924,6 +8984,41 @@ function viewProfile(uid) { |
| modRow.appendChild(reportBtn); |
| } |
| card.appendChild(modRow); |
| + |
| + // Block (issue #578): mute + end the DM + server-enforced no new contact |
| + // — no new DMs, no adding each other to groups. They're never told. |
| + if (!e.is_agent) { |
| + const blockRow = document.createElement("div"); |
| + blockRow.className = "row"; |
| + blockRow.style.cssText = "justify-content:center;margin-top:10px;flex-direction:column;align-items:center;gap:6px"; |
| + const blockBtn = document.createElement("button"); |
| + const blockNote = document.createElement("div"); |
| + blockNote.className = "field-note"; |
| + blockNote.style.textAlign = "center"; |
| + const paintBlock = () => { |
| + const on = blockedPeople.has(uid); |
| + blockBtn.className = on ? "secondary" : "danger"; |
| + blockBtn.textContent = on |
| + ? `Unblock ${displayNameFor(nicknames[uid], e.label) || "them"}` |
| + : `Block ${displayNameFor(nicknames[uid], e.label) || "them"}`; |
| + blockNote.textContent = on |
| + ? "Unblocking makes contact possible again — nothing reopens by itself." |
| + : "Blocking mutes them, ends your direct messages, and stops new contact both ways. They won't be told."; |
| + }; |
| + paintBlock(); |
| + blockBtn.onclick = async () => { |
| + blockBtn.disabled = true; |
| + const ok = await setPersonBlocked(uid, !blockedPeople.has(uid)); |
| + blockBtn.disabled = false; |
| + if (!ok) { |
| + blockNote.textContent = "Couldn't save — try again."; |
| + return; |
| + } |
| + paintBlock(); |
| + }; |
| + blockRow.append(blockBtn, blockNote); |
| + card.appendChild(blockRow); |
| + } |
| } |
| } |