eleven / messenger Public

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>
Paul Campbell 399cf87e03f8117896a8ed2015842b19d6473e15 parent fc2e4a6
11 files changed, +575 −11
  • blocks_test.go +123 −0
  • changelog/2026-07-12-block.md +7 −0
  • db.go +73 −0
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +46 −0
  • ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift +38 −0
  • ios/LChatCore/Sources/LChatCore/ReportSheet.swift +14 −3
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +37 −0
  • main.go +115 −2
  • miniapps.go +5 −2
  • push.go +18 −0
  • web/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);
+ }
}
}