eleven / messenger Public

Moderation 2/6: DM privacy + end conversation (#578) (#755)

* 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>

* 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>

* 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>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8da3e84043db57c2bf339341640128c85520ae48 parent b2e74bc
23 files changed, +1088 −31
  • changelog/2026-07-12-dm-privacy.md +5 −0
  • db.go +59 −1
  • dmprivacy_test.go +189 −0
  • hub.go +8 −0
  • ios/LChat/Views/NativeChatScreen.swift +76 −8
  • ios/LChat/Views/ServerSettingsSheet.swift +46 −0
  • ios/LChat/Views/ThreadListView.swift +7 −0
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +19 −0
  • ios/LChatCore/Sources/LChatCore/ChatSocket.swift +2 −1
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +65 −5
  • ios/LChatCore/Sources/LChatCore/Models.swift +2 −0
  • ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift +11 −0
  • ios/LChatCore/Sources/LChatCore/SpaceCreate.swift +6 −3
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +77 −5
  • ios/LChatMac/MacSettingsView.swift +50 −0
  • ios/LChatMac/NativeChatView.swift +43 −0
  • ios/LChatMac/NativeFeedView.swift +57 −5
  • main.go +163 −0
  • miniapps.go +7 −0
  • web/pages/index.html +1 −0
  • web/static/apps/chat.js +46 −0
  • web/static/shell.js +135 −3
  • web/static/style.css +14 −0
diff --git a/changelog/2026-07-12-dm-privacy.md b/changelog/2026-07-12-dm-privacy.md
new file mode 100644
index 0000000..e027ef9
--- /dev/null
+++ b/changelog/2026-07-12-dm-privacy.md
@@ -0,0 +1,5 @@
+๐Ÿค– You're now in charge of your direct messages. In Edit profile, choose who
+can start a DM with you: everyone in the space, or no one (admins and helper
+bots can always reach you, and chats you already have keep working). And any
+DM can now be ended from its โ‹ฎ menu โ€” it closes for both of you and stays
+closed until either of you reopens it.
diff --git a/db.go b/db.go
index 3ed87b0..09b13eb 100644
--- a/db.go
+++ b/db.go
@@ -403,6 +403,16 @@ CREATE TABLE IF NOT EXISTS muted_threads (
thread_id TEXT NOT NULL,
PRIMARY KEY (user_id, thread_id)
);
+-- a closed DM (issue #578): either party ended the conversation. The thread
+-- and its history stay (the words belong to both people), but the server
+-- refuses new messages/edits/typing into it until someone reopens it โ€” a
+-- client-side-only close would be bypassable. Connection metadata only: who
+-- closed and when, never why.
+CREATE TABLE IF NOT EXISTS closed_dms (
+ thread_id TEXT PRIMARY KEY,
+ closed_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL
+);
-- 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.
@@ -747,6 +757,13 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_checkpoints_seq_from ON checkpoints(seq_fr
// read who muted whom, it just syncs the ciphertext across their devices.
"ALTER TABLE users ADD COLUMN prefs_blob TEXT",
"ALTER TABLE users ADD COLUMN prefs_version INTEGER NOT NULL DEFAULT 0",
+ // DM privacy (issue #578): who may open a NEW direct message with this
+ // member. '' = everyone in the space (the default, today's behaviour);
+ // 'nobody' = no one โ€” with a disclosed carve-out for admins and bots
+ // (the operator must stay reachable; onboarding speaks through Eleven's
+ // welcome DM). Enforced server-side at DM creation, where a client-only
+ // check would be bypassable. A routing preference, never content.
+ "ALTER TABLE users ADD COLUMN dm_privacy TEXT NOT NULL DEFAULT ''",
} {
if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -2558,6 +2575,10 @@ type Thread struct {
// Muted is true when the requesting user has muted this chat โ€” no pushes,
// no unread-badge escalation on their devices.
Muted bool `json:"muted,omitempty"`
+ // DMClosed is true when either party ended this DM (issue #578): the
+ // server refuses new messages until someone reopens it, and clients show
+ // the closed banner instead of the composer.
+ DMClosed bool `json:"dm_closed,omitempty"`
// Visibility is "open" for a community group any member can discover and
// join (see openThreads); omitted for ordinary invite-only threads.
Visibility string `json:"visibility,omitempty"`
@@ -2684,6 +2705,7 @@ func (db *DB) deleteThread(id string) error {
for _, q := range []string{
"DELETE FROM thread_keys WHERE thread_id = ?",
"DELETE FROM thread_members WHERE thread_id = ?",
+ "DELETE FROM closed_dms WHERE thread_id = ?",
"DELETE FROM threads WHERE id = ?",
} {
if _, err := tx.Exec(q, id); err != nil {
@@ -2724,6 +2746,7 @@ func (db *DB) threadsForUser(userID string) ([]Thread, error) {
EXISTS(SELECT 1 FROM hidden_threads h WHERE h.thread_id = t.id AND h.user_id = m.user_id),
EXISTS(SELECT 1 FROM pinned_threads p WHERE p.thread_id = t.id AND p.user_id = m.user_id) AS pinned,
EXISTS(SELECT 1 FROM muted_threads mu WHERE mu.thread_id = t.id AND mu.user_id = m.user_id),
+ EXISTS(SELECT 1 FROM closed_dms c WHERE c.thread_id = t.id),
COALESCE((SELECT last_read_at FROM read_marks r WHERE r.thread_id = t.id AND r.user_id = m.user_id), 0)
FROM threads t
JOIN thread_members m ON m.thread_id = t.id
@@ -2742,7 +2765,7 @@ func (db *DB) threadsForUser(userID string) ([]Thread, error) {
out := []Thread{}
for rows.Next() {
var t Thread
- if err := rows.Scan(&t.ID, &t.Kind, &t.NameBlob, &t.CreatedAt, &t.Wrapped, &t.LastBlob, &t.LastAt, &t.Avatar, &t.Visibility, &t.OpenKey, &t.OpenTier, &t.DefaultJoin, &t.Hidden, &t.Pinned, &t.Muted, &t.LastReadAt); err != nil {
+ if err := rows.Scan(&t.ID, &t.Kind, &t.NameBlob, &t.CreatedAt, &t.Wrapped, &t.LastBlob, &t.LastAt, &t.Avatar, &t.Visibility, &t.OpenKey, &t.OpenTier, &t.DefaultJoin, &t.Hidden, &t.Pinned, &t.Muted, &t.DMClosed, &t.LastReadAt); err != nil {
return nil, err
}
if t.Visibility == "invite" {
@@ -2974,6 +2997,41 @@ func (db *DB) setThreadPinned(userID, threadID string, pinned bool) error {
return err
}
+// userDMPrivacy returns a member's DM-privacy setting: "" (everyone, the
+// default) or "nobody".
+func (db *DB) userDMPrivacy(userID string) string {
+ var v string
+ db.sql.QueryRow("SELECT COALESCE(dm_privacy, '') FROM users WHERE id = ?", userID).Scan(&v)
+ return v
+}
+
+func (db *DB) setUserDMPrivacy(userID, v string) error {
+ _, err := db.sql.Exec("UPDATE users SET dm_privacy = ? WHERE id = ?", v, userID)
+ return err
+}
+
+// closeDM marks a DM closed: the server stops accepting new traffic into it
+// until reopenDM. Idempotent โ€” the first closer wins the closed_by credit.
+func (db *DB) closeDM(threadID, userID string) error {
+ _, err := db.sql.Exec(
+ "INSERT OR IGNORE INTO closed_dms(thread_id, closed_by, created_at) VALUES(?, ?, ?)",
+ threadID, userID, time.Now().Unix(),
+ )
+ return err
+}
+
+func (db *DB) reopenDM(threadID string) error {
+ _, err := db.sql.Exec("DELETE FROM closed_dms WHERE thread_id = ?", threadID)
+ return err
+}
+
+// dmClosed reports whether a DM is closed, and by whom.
+func (db *DB) dmClosed(threadID string) (bool, string) {
+ var by string
+ err := db.sql.QueryRow("SELECT closed_by FROM closed_dms WHERE thread_id = ?", threadID).Scan(&by)
+ return err == nil, by
+}
+
// 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/dmprivacy_test.go b/dmprivacy_test.go
new file mode 100644
index 0000000..1502275
--- /dev/null
+++ b/dmprivacy_test.go
@@ -0,0 +1,189 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+)
+
+func modPost(t *testing.T, url, body string) (int, map[string]any) {
+ t.Helper()
+ resp, err := http.Post(url, "application/json", strings.NewReader(body))
+ 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
+}
+
+// dmBody builds a two-member DM create payload (wraps are opaque to the server).
+func dmBody(tok, a, b string) string {
+ return `{"token":"` + tok + `","kind":"dm","members":[{"user_id":"` + a + `","wrapped":"wa"},{"user_id":"` + b + `","wrapped":"wb"}]}`
+}
+
+// The DM door: 'nobody' stops NEW DMs from ordinary members server-side, while
+// admins and bots pass (the disclosed carve-out) and existing DMs are untouched.
+func TestDMPrivacy(t *testing.T) {
+ ts, srv := newTestServer(t)
+
+ tokA, uidA, _ := joinedMember(t, srv, "Alice")
+ tokB, uidB, _ := joinedMember(t, srv, "Bob")
+ _ = tokA
+
+ // Alice closes her door.
+ if code, _ := modPost(t, ts.URL+"/api/dm-privacy", `{"token":"`+tokA+`","dm_privacy":"nobody"}`); code != 200 {
+ t.Fatalf("set dm_privacy: got %d", code)
+ }
+ // /api/me reflects it.
+ resp, err := http.Get(ts.URL + "/api/me?token=" + tokA)
+ if err != nil {
+ t.Fatalf("me: %v", err)
+ }
+ var me map[string]any
+ _ = json.NewDecoder(resp.Body).Decode(&me)
+ resp.Body.Close()
+ if me["dm_privacy"] != "nobody" {
+ t.Fatalf("me.dm_privacy: got %v", me["dm_privacy"])
+ }
+
+ // Bob can't open a NEW DM with Alice.
+ if code, _ := modPost(t, ts.URL+"/api/threads", dmBody(tokB, uidB, uidA)); code != 403 {
+ t.Fatalf("member DM against a closed door should be 403, got %d", code)
+ }
+ // Alice can still DM Bob โ€” the door is hers, not his.
+ if code, _ := modPost(t, ts.URL+"/api/threads", dmBody(tokA, uidA, uidB)); code != 200 {
+ t.Fatalf("Alice opening the DM herself should be 200")
+ }
+ // And with the pair existing, Bob "creating" it dedupes to the existing
+ // thread โ€” history is untouched by the setting.
+ if code, out := modPost(t, ts.URL+"/api/threads", dmBody(tokB, uidB, uidA)); code != 200 || out["existing"] != true {
+ t.Fatalf("existing DM should dedupe regardless of the door, got %d %v", code, out)
+ }
+
+ // An admin passes a closed door.
+ tokAdm, uidAdm, _ := joinedMember(t, srv, "Root")
+ if err := srv.db.setUserRole(uidAdm, "admin"); err != nil {
+ t.Fatalf("setUserRole: %v", err)
+ }
+ // Carol also closes her door (fresh pair, no existing DM).
+ tokC, uidC, _ := joinedMember(t, srv, "Carol")
+ _ = tokC
+ if code, _ := modPost(t, ts.URL+"/api/dm-privacy", `{"token":"`+tokC+`","dm_privacy":"nobody"}`); code != 200 {
+ t.Fatal("set carol dm_privacy")
+ }
+ if code, _ := modPost(t, ts.URL+"/api/threads", dmBody(tokAdm, uidAdm, uidC)); code != 200 {
+ t.Fatal("admin should pass a closed DM door")
+ }
+
+ // A bot passes too (Eleven's welcome DM).
+ tokBot, uidBot, _ := joinedMember(t, srv, "Eleven")
+ if err := srv.db.setUserAgent(uidBot); err != nil {
+ t.Fatalf("setUserAgent: %v", err)
+ }
+ tokD, uidD, _ := joinedMember(t, srv, "Dana")
+ _ = tokD
+ if code, _ := modPost(t, ts.URL+"/api/dm-privacy", `{"token":"`+tokD+`","dm_privacy":"nobody"}`); code != 200 {
+ t.Fatal("set dana dm_privacy")
+ }
+ if code, _ := modPost(t, ts.URL+"/api/threads", dmBody(tokBot, uidBot, uidD)); code != 200 {
+ t.Fatal("bot should pass a closed DM door")
+ }
+}
+
+// Closing a DM ends the conversation server-side: it hides for both, refuses
+// new messages (REST here; the WS path shares the gate), kills queued
+// scheduled deliveries, and reopening follows the other side's DM door.
+func TestCloseDM(t *testing.T) {
+ ts, srv := newTestServer(t)
+
+ tokA, uidA, _ := joinedMember(t, srv, "Alice")
+ tokB, uidB, _ := joinedMember(t, srv, "Bob")
+
+ // Their DM.
+ 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)
+
+ // Bob schedules a future message into it (due immediately).
+ due := fmt.Sprintf("%d", time.Now().Unix())
+ if code, _ := modPost(t, ts.URL+"/api/schedule", `{"token":"`+tokB+`","thread_id":"`+tid+`","deliver_at":`+due+`,"blob":"future"}`); code != 200 {
+ t.Fatalf("schedule: %d", code)
+ }
+
+ // Alice closes the conversation.
+ if code, _ := modPost(t, ts.URL+"/api/threads/"+tid+"/close-dm", `{"token":"`+tokA+`"}`); code != 200 {
+ t.Fatal("close-dm should succeed for a member")
+ }
+
+ // New messages bounce (both directions).
+ if code, _ := modPost(t, ts.URL+"/api/messages?token="+tokB, `{"thread_id":"`+tid+`","blob":"hi"}`); code != 403 {
+ t.Fatalf("message into a closed DM should be 403, got %d", code)
+ }
+ if code, _ := modPost(t, ts.URL+"/api/messages?token="+tokA, `{"thread_id":"`+tid+`","blob":"hi"}`); code != 403 {
+ t.Fatalf("closer's own message should also be 403, got %d", code)
+ }
+
+ // The queued delivery dies instead of forcing the thread open.
+ if n := srv.sweepDueScheduled(); n != 0 {
+ t.Fatalf("scheduled delivery into a closed DM should not fire, delivered %d", n)
+ }
+ if due, _ := srv.db.dueScheduledMessages(1<<60, 10); len(due) != 0 {
+ t.Fatalf("dead scheduled row should be dropped, still have %d", len(due))
+ }
+
+ // Hidden for both; flagged closed for both.
+ for _, tok := range []string{tokA, tokB} {
+ resp, err := http.Get(ts.URL + "/api/threads?token=" + tok)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got struct {
+ Threads []Thread `json:"threads"`
+ }
+ _ = json.NewDecoder(resp.Body).Decode(&got)
+ resp.Body.Close()
+ found := false
+ for _, th := range got.Threads {
+ if th.ID == tid {
+ found = true
+ if !th.Hidden || !th.DMClosed {
+ t.Fatalf("closed DM should be hidden+dm_closed, got hidden=%v closed=%v", th.Hidden, th.DMClosed)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("closed DM should still be listed (hidden), not gone")
+ }
+ }
+
+ // Bob can't reopen while Alice's door is shutโ€ฆ
+ if code, _ := modPost(t, ts.URL+"/api/dm-privacy", `{"token":"`+tokA+`","dm_privacy":"nobody"}`); code != 200 {
+ t.Fatal("set door")
+ }
+ if code, _ := modPost(t, ts.URL+"/api/threads/"+tid+"/reopen-dm", `{"token":"`+tokB+`"}`); code != 403 {
+ t.Fatal("reopen against a closed door should be 403")
+ }
+ // โ€ฆAlice can always reopen her own conversation.
+ if code, _ := modPost(t, ts.URL+"/api/threads/"+tid+"/reopen-dm", `{"token":"`+tokA+`"}`); code != 200 {
+ t.Fatal("closer reopening should succeed")
+ }
+ if code, _ := modPost(t, ts.URL+"/api/messages?token="+tokB, `{"thread_id":"`+tid+`","blob":"hi again"}`); code != 200 {
+ t.Fatal("messages should flow again after reopen")
+ }
+
+ // Group threads can't be "closed" โ€” DMs only.
+ gid, err := srv.db.createThread("group", "", []ThreadMemberKey{{UserID: uidA, Wrapped: "wa"}, {UserID: uidB, Wrapped: "wb"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if code, _ := modPost(t, ts.URL+"/api/threads/"+gid+"/close-dm", `{"token":"`+tokA+`"}`); code != 400 {
+ t.Fatal("close-dm on a group should be 400")
+ }
+}
diff --git a/hub.go b/hub.go
index 595b421..923b3d4 100644
--- a/hub.go
+++ b/hub.go
@@ -270,6 +270,14 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
if in.ThreadID == "" || !s.db.threadMember(in.ThreadID, userID) {
continue
}
+ // A closed DM (issue #578) accepts no new traffic โ€” messages, edits,
+ // and typing are all communication. Deletes still pass: taking your
+ // own words back is always allowed.
+ if in.Type != "delete" {
+ if closed, _ := s.db.dmClosed(in.ThreadID); closed {
+ continue
+ }
+ }
switch in.Type {
case "typing":
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 1c42237..8ccf2b1 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -171,6 +171,11 @@ struct NativeChatScreen: View {
@State private var showMembers = false
@State private var showRename = false
@State private var showGroupApps = false
+ // End conversation (#578): the destructive confirm, and the closed
+ // banner's in-flight / refusal state (inline copy, never an alert).
+ @State private var showEndConfirm = false
+ @State private var reopenBusy = false
+ @State private var reopenNote: String?
// The group's chosen view for this session: nil = the conversation, an app
// id = that shared app's interface (GroupAppMemory keeps it across pushes,
@@ -311,14 +316,21 @@ struct NativeChatScreen: View {
// An app surface owns the whole pane, inputs included โ€” the
// chat composer steps aside (ChatView's interface-view rule).
if activeGroupAppID == nil {
- composerCluster
- .background(GeometryReader { g in
- // Measured for the feed's inset-growth re-pin โ€”
- // a background probe, so it never shapes layout.
- Color.clear
- .onAppear { composerHeight = g.size.height }
- .onChange(of: g.size.height) { _, h in composerHeight = h }
- })
+ // An ended DM (#578): the server refuses new messages, so
+ // the composer gives way to the closed banner โ€” the one
+ // honest action left is Reopen.
+ if model.dmClosed {
+ dmClosedBanner
+ } else {
+ composerCluster
+ .background(GeometryReader { g in
+ // Measured for the feed's inset-growth re-pin โ€”
+ // a background probe, so it never shapes layout.
+ Color.clear
+ .onAppear { composerHeight = g.size.height }
+ .onChange(of: g.size.height) { _, h in composerHeight = h }
+ })
+ }
}
}
.toolbar {
@@ -384,12 +396,34 @@ struct NativeChatScreen: View {
} label: {
Label("Close chat", systemImage: "xmark.circle")
}
+ // End conversation (#578): DM-only, stronger than Close
+ // chat โ€” mutual and server-enforced, hence the confirm.
+ // Once ended, the banner's Reopen is the way back.
+ if thread.kind == "dm", !model.dmClosed {
+ Button(role: .destructive) {
+ showEndConfirm = true
+ } label: {
+ Label("End conversation", systemImage: "nosign")
+ }
+ }
} label: {
Image(systemName: "ellipsis.circle")
}
.accessibilityLabel("Chat options")
}
}
+ .confirmationDialog("End this conversation?",
+ isPresented: $showEndConfirm, titleVisibility: .visible) {
+ Button("End conversation", role: .destructive) {
+ Task {
+ // Optimistic in the store; a refused POST reverts and
+ // we simply stay in the chat, unchanged.
+ if await store.endConversation(thread) { dismiss() }
+ }
+ }
+ } message: {
+ Text("It closes for both of you, and no new messages can be sent unless someone reopens it.")
+ }
.sheet(isPresented: $showMembers) { MembersSheet(store: store, thread: thread) }
.sheet(isPresented: $showRename) { RenameGroupSheet(store: store, thread: thread) }
.sheet(isPresented: $showGroupApps) { GroupAppsSheet(model: model) }
@@ -1146,6 +1180,40 @@ struct NativeChatScreen: View {
// ---- composer ------------------------------------------------------------
+ // The ended-DM banner that takes the composer's place (#578): inline in
+ // the composer's layout slot โ€” never floating over the feed. A refused
+ // reopen swaps the line for its reason (their DM door / a blip), the
+ // web's dm-closed-bar behaviour.
+ private var dmClosedBanner: some View {
+ HStack(spacing: 12) {
+ Text(reopenNote ?? "This conversation is closed.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ Button("Reopen") {
+ reopenNote = nil
+ reopenBusy = true
+ Task {
+ switch await store.reopenDM(thread.id) {
+ case .ok:
+ break // the flag flips (store + model); the composer returns
+ case .dmsClosed:
+ reopenNote = "They're not accepting direct messages right now."
+ case .failed:
+ reopenNote = "Couldn't reopen โ€” try again."
+ }
+ reopenBusy = false
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(reopenBusy)
+ .accessibilityHint("Reopens this conversation for both of you")
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ .background(.bar)
+ }
+
private var composerCluster: some View {
VStack(spacing: 6) {
if let ta = nativeTypeahead {
diff --git a/ios/LChat/Views/ServerSettingsSheet.swift b/ios/LChat/Views/ServerSettingsSheet.swift
index de2b502..7b06cc6 100644
--- a/ios/LChat/Views/ServerSettingsSheet.swift
+++ b/ios/LChat/Views/ServerSettingsSheet.swift
@@ -18,6 +18,13 @@ struct ServerSettingsSheet: View {
@State private var awayLoaded = false // gate the toggle until /api/me answers
@State private var awayNote: String?
@State private var hasPaidLevels = false // this space sells (or I'm subscribed to) an access level
+ // DM privacy (#578): who may START a direct message with you. Same
+ // load/save discipline as Away โ€” gated until /api/me answers, saved on
+ // change, reverted with an inline note if the save doesn't stick.
+ @State private var dmPrivacy = "everyone"
+ @State private var savedDMPrivacy = "everyone"
+ @State private var dmPrivacyLoaded = false
+ @State private var dmPrivacyNote: String?
var body: some View {
NavigationStack {
@@ -39,6 +46,21 @@ struct ServerSettingsSheet: View {
} footer: {
Text("Your dot stays yellow until you switch this off โ€” being active won't flip you back to online.")
}
+ Section {
+ Picker("Who can message you directly", selection: $dmPrivacy) {
+ Text("Everyone in this space").tag("everyone")
+ Text("No one").tag("nobody")
+ }
+ .disabled(!dmPrivacyLoaded)
+ .onChange(of: dmPrivacy) { _, next in setDMPrivacy(next) }
+ if let dmPrivacyNote {
+ Text(dmPrivacyNote).font(.footnote).foregroundStyle(.secondary)
+ }
+ } header: {
+ Text("Direct messages")
+ } footer: {
+ Text("Existing chats keep working. Admins and helper bots can always reach you.")
+ }
if hasPaidLevels {
Section {
NavigationLink {
@@ -90,6 +112,17 @@ struct ServerSettingsSheet: View {
awayNote = "Couldn't load your status."
}
}
+ .task {
+ if let current = await store.loadDMPrivacy() {
+ // The server stores "everyone" as "" โ€” normalise for the tags.
+ let v = current == "nobody" ? "nobody" : "everyone"
+ savedDMPrivacy = v
+ dmPrivacy = v
+ dmPrivacyLoaded = true
+ } else {
+ dmPrivacyNote = "Couldn't load this setting."
+ }
+ }
.task {
// Only surface Membership where paid access levels exist (or the
// member already has a subscription) โ€” no dead entry otherwise.
@@ -111,6 +144,19 @@ struct ServerSettingsSheet: View {
}
}
+ private func setDMPrivacy(_ next: String) {
+ guard dmPrivacyLoaded, next != savedDMPrivacy else { return } // skip programmatic sets
+ dmPrivacyNote = nil
+ Task {
+ if await store.setDMPrivacy(next) {
+ savedDMPrivacy = next
+ } else {
+ dmPrivacy = savedDMPrivacy // didn't stick โ€” put it back, say so inline
+ dmPrivacyNote = "Couldn't save โ€” try again."
+ }
+ }
+ }
+
private var dirty: Bool {
let trimmed = name.trimmingCharacters(in: .whitespaces)
return !trimmed.isEmpty && trimmed != store.instance.screenName
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
index 73a6c37..9151c82 100644
--- a/ios/LChat/Views/ThreadListView.swift
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -449,6 +449,13 @@ struct ThreadListView: View {
store.applyPresence(userID: uid, state: state)
return
}
+ // A DM ended or revived (#578), by either side, maybe elsewhere โ€”
+ // fold the flag in place like presence; the frame is the change.
+ if frame.type == "dm_closed" || frame.type == "dm_reopened",
+ let tid = frame.thread_id {
+ store.applyDMClosed(threadID: tid, closed: frame.type == "dm_closed")
+ return
+ }
guard frame.ephemeral != true,
["msg", "edit", "delete"].contains(frame.type) else { return }
guard !refreshPending else { return }
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index 58973c4..8fa4e78 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -124,6 +124,10 @@ public final class ChatFeedModel: ObservableObject {
private var idleTimer: Timer?
/// True once scroll-back has reached the beginning of the thread.
@Published public private(set) var exhausted = false
+ /// An ended DM (issue #578): the server refuses new messages for both
+ /// parties, so the feed's composer gives way to the closed banner. Seeded
+ /// from the thread row, kept live by the dm_closed/dm_reopened frames.
+ @Published public private(set) var dmClosed = false
/// True once the first history page is in โ€” the scroll-back trigger must
/// not fire during initial render.
@Published public private(set) var ready = false
@@ -206,6 +210,15 @@ public final class ChatFeedModel: ObservableObject {
self.myUserId = myUserId
self.api = instance.baseURL.map { LChatAPI(baseURL: $0, token: instance.deviceToken) }
self.key = Data(base64urlEncoded: thread.threadKeyB64).flatMap { try? GroupKey(rawKey: $0) }
+ self.dmClosed = thread.dmClosed
+ }
+
+ /// Adopt a fresher closed-flag than the live frames delivered โ€” the cache
+ /// syncs a parked model from the row it's re-handed (the other side may
+ /// have acted while the app was suspended), and ThreadStore's own
+ /// close/reopen applies here without waiting for the echo frame.
+ public func noteDMClosed(_ closed: Bool) {
+ if dmClosed != closed { dmClosed = closed }
}
/// Where an attachment's encrypted bytes live + the key to open them โ€” an
@@ -1120,6 +1133,12 @@ public final class ChatFeedModel: ObservableObject {
Task.detached(priority: .utility) {
await MessageStore.shared.delete(instance: instanceID, thread: tid, id: id)
}
+ case "dm_closed", "dm_reopened":
+ // Either side ended (or revived) this DM (issue #578) โ€” the open
+ // pane swaps between composer and closed banner live, the web's
+ // renderActiveSurfaces on these frames. The sidebar row rides the
+ // store's socket separately.
+ dmClosed = frame.type == "dm_closed"
default:
break // presence/read ride the store's socket; unknown types ignored
}
diff --git a/ios/LChatCore/Sources/LChatCore/ChatSocket.swift b/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
index b5519d4..cce375f 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatSocket.swift
@@ -9,7 +9,8 @@ public final class ChatSocket: NSObject, URLSessionWebSocketDelegate {
// One hub frame, decoded leniently: the hub sends several shapes (msg /
// edit / delete / typing) that share `type` + `thread_id`; fields
// the shape doesn't carry just stay nil. Reactions are now encrypted events
- // (type="reaction") inside "msg" frames.
+ // (type="reaction") inside "msg" frames. dm_closed / dm_reopened (#578)
+ // carry only type + thread_id and need no fields of their own.
public struct Frame: Decodable {
public let type: String
public let thread_id: String?
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index 2dea9ae..31a0cda 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -190,11 +190,17 @@ public struct LChatAPI {
return (try? await getJSON("/api/messages", Resp.self, query: items))?.messages ?? []
}
+ /// Why createThread failed: .dmsClosed = the target's DM door is shut
+ /// (403 {"error":"dms closed"}, issue #578 โ€” admins and bots pass
+ /// server-side), so callers can answer with honest copy; anything else
+ /// stays a generic failure.
+ public enum CreateThreadError: Error { case dmsClosed, failed }
+
/// Creates a thread (self/dm/group) with the per-member wrapped keys. Returns the
/// new (or, for a deduped DM, existing) thread id. `members` is [{user_id, wrapped}].
public func createThread(kind: String, nameBlob: String, members: [[String: String]],
- openKey: String = "") async -> String? {
- struct Resp: Codable { let thread_id: String? }
+ openKey: String = "") async -> Result<String, CreateThreadError> {
+ struct Resp: Codable { let thread_id: String?; let error: String? }
var body: [String: Any] = ["token": token, "kind": kind, "name_blob": nameBlob, "members": members]
if !openKey.isEmpty {
// An open community group: the thread key encrypted under the
@@ -203,14 +209,15 @@ public struct LChatAPI {
body["open_key"] = openKey
}
guard let url = URL(string: "/api/threads", relativeTo: baseURL),
- let data = try? JSONSerialization.data(withJSONObject: body) else { return nil }
+ let data = try? JSONSerialization.data(withJSONObject: body) else { return .failure(.failed) }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = data
guard let (respData, _) = try? await session.data(for: req),
- let r = try? JSONDecoder().decode(Resp.self, from: respData) else { return nil }
- return r.thread_id
+ let r = try? JSONDecoder().decode(Resp.self, from: respData) else { return .failure(.failed) }
+ if let tid = r.thread_id { return .success(tid) }
+ return .failure((r.error ?? "").contains("dms closed") ? .dmsClosed : .failed)
}
/// Schedule an encrypted event for future delivery into a thread (the web's
@@ -572,6 +579,59 @@ public struct LChatAPI {
await postJSON("api/presence", ["token": token, "away": away])
}
+ /// The caller's DM door (GET /api/me): "" (or "everyone") = anyone in the
+ /// space may start a DM with them; "nobody" = only admins and helper bots
+ /// (issue #578). nil when the fetch failed or the server predates the
+ /// setting โ€” callers keep their picker gated, like the away toggle.
+ public func dmPrivacy() async -> String? {
+ struct Resp: Codable { let dm_privacy: String? }
+ return (try? await getJSON("/api/me", Resp.self))?.dm_privacy
+ }
+
+ /// Set who may START a DM with you โ€” "everyone" or "nobody" (POST
+ /// /api/dm-privacy). Existing chats keep working; admins and bots always
+ /// pass (the operator must stay reachable, Eleven's welcome DM is how
+ /// onboarding speaks).
+ @discardableResult
+ public func setDMPrivacy(_ value: String) async -> Bool {
+ await postJSON("api/dm-privacy", ["token": token, "dm_privacy": value])
+ }
+
+ /// End a DM for BOTH parties (POST close-dm, issue #578): the server hides
+ /// it on both sides and refuses new messages until someone reopens it.
+ /// Strict about the status โ€” endConversation reverts its optimistic close
+ /// on anything but a real 200.
+ public func closeDM(thread: String) async -> Bool {
+ guard let url = URL(string: "api/threads/\(thread)/close-dm", relativeTo: baseURL),
+ let data = try? JSONSerialization.data(withJSONObject: ["token": token]) 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
+ }
+
+ /// reopenDM's outcome: .dmsClosed = the OTHER side's DM door is shut
+ /// (403 {"error":"dms closed"}) โ€” the one refusal with its own copy.
+ public enum ReopenDM { case ok, dmsClosed, failed }
+
+ /// Reopen an ended DM (POST reopen-dm) โ€” for both parties, like ending it.
+ public func reopenDM(thread: String) async -> ReopenDM {
+ guard let url = URL(string: "api/threads/\(thread)/reopen-dm", relativeTo: baseURL),
+ let data = try? JSONSerialization.data(withJSONObject: ["token": token]) else { return .failed }
+ var req = URLRequest(url: url)
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = data
+ guard let (body, resp) = try? await session.data(for: req),
+ let code = (resp as? HTTPURLResponse)?.statusCode else { return .failed }
+ if code == 200 { return .ok }
+ struct R: Codable { let error: String? }
+ let err = (try? JSONDecoder().decode(R.self, from: body))?.error ?? ""
+ return err.contains("dms closed") ? .dmsClosed : .failed
+ }
+
/// 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/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift
index 2ed7b4f..daabebf 100644
--- a/ios/LChatCore/Sources/LChatCore/Models.swift
+++ b/ios/LChatCore/Sources/LChatCore/Models.swift
@@ -82,6 +82,7 @@ public struct ServerThread: Codable {
public var avatar: String?
public var pinned: Bool?
public var hidden: Bool? // this user "closed" the chat (per-user, server-synced)
+ public var dm_closed: Bool? // an ended DM โ€” the server refuses new messages for BOTH parties (#578)
public var muted: Bool? // this user muted the chat (per-user, server-synced; server skips its pushes)
public var last_read_at: Int64? // this user's read watermark (per-user, server-synced)
public var visibility: String? // "open" for a community group anyone can join
@@ -364,6 +365,7 @@ public struct ChatThread: Identifiable, Equatable, Codable {
public var unread: Bool
public var pinned: Bool // user pinned this chat to the top (set on the web/Mac client)
public var hidden: Bool // user "closed" this chat: dropped from the list, still findable in search
+ public var dmClosed = false // an ended DM (mutual, server-enforced): the composer gives way to the closed banner (#578)
// User muted this chat: no unread dot, no notifications (the server skips
// its pushes too). Optional so parked ThreadListSnapshot rows from before
// the field decode โ€” missing reads as unmuted, via isMuted.
diff --git a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift
index b43b76c..22a51af 100644
--- a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift
+++ b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift
@@ -103,6 +103,9 @@ public final class NativeFeedModelCache {
let model = entry.model
model.integrity = integrityMonitor(for: instance)
Task { await model.catchUpFromStore() }
+ // Same staleness for an ended DM (#578): the row the caller holds
+ // is fresher than a park that slept through the dm_closed frame.
+ model.noteDMClosed(thread.dmClosed)
return entry.model
}
entries.removeValue(forKey: key)?.model.stop()
@@ -116,6 +119,14 @@ public final class NativeFeedModelCache {
return model
}
+ /// Flip a parked model's ended-DM flag (#578): ThreadStore's own
+ /// close/reopen reaches an open pane immediately, without waiting for the
+ /// server's echo frame. A thread with no parked model is a no-op โ€” the
+ /// next model is seeded from its (already updated) row.
+ public func noteDMClosed(instance instanceID: String, thread threadID: String, closed: Bool) {
+ entries[instanceID + "|" + threadID]?.model.noteDMClosed(closed)
+ }
+
/// Warm the top of the sidebar so first taps land on already-folded feeds.
public func warm(instance: SavedInstance, threads: [ChatThread], myUserId: String) {
for thread in threads.prefix(Self.prewarmCount) where !thread.threadKeyB64.isEmpty {
diff --git a/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift b/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift
index 0d42353..34f40dc 100644
--- a/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift
+++ b/ios/LChatCore/Sources/LChatCore/SpaceCreate.swift
@@ -288,9 +288,12 @@ public enum SpaceSetup {
return (nameBlob, wrapped)
}.value
guard let built else { return false }
- return await api.createThread(kind: "self", nameBlob: built.nameBlob,
- members: [["user_id": inst.myUserId,
- "wrapped": built.wrapped]]) != nil
+ if case .success = await api.createThread(kind: "self", nameBlob: built.nameBlob,
+ members: [["user_id": inst.myUserId,
+ "wrapped": built.wrapped]]) {
+ return true
+ }
+ return false
}
/// Up to three attempts with a short breath between โ€” the call runs right
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index 9e45c95..9f635ef 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -504,6 +504,7 @@ public final class ThreadStore: ObservableObject {
manualUnreadAt: ReadState.manualUnreadAt(instance: instance.id, thread: t.id)),
pinned: t.pinned ?? false,
hidden: t.hidden ?? false,
+ dmClosed: t.dm_closed ?? false,
muted: t.muted,
members: members,
threadKeyB64: raw.base64urlEncodedString(),
@@ -900,6 +901,68 @@ public final class ThreadStore: ObservableObject {
setHidden(threads[i], false)
}
+ // MARK: DM privacy + end conversation (#578)
+
+ /// End a DM for BOTH people (the web's endConversation): it drops off both
+ /// lists and the server refuses new messages until someone reopens it.
+ /// Optimistic โ€” the row marks closed+hidden immediately (and the parked
+ /// feed model swaps its composer for the banner); a refused POST puts
+ /// everything back, so a network blip never fakes a mutual close.
+ public func endConversation(_ thread: ChatThread) async -> Bool {
+ guard thread.kind == "dm", let api else { return false }
+ setDMClosedLocally(thread.id, true)
+ guard await api.closeDM(thread: thread.id) else {
+ setDMClosedLocally(thread.id, false)
+ return false
+ }
+ return true
+ }
+
+ /// Reopen an ended DM โ€” for both parties, like ending it. .dmsClosed means
+ /// the OTHER side's DM door is shut (their choice wins; admins/bots pass
+ /// server-side), so the banner can say so honestly.
+ public func reopenDM(_ threadID: String) async -> LChatAPI.ReopenDM {
+ guard let api else { return .failed }
+ let r = await api.reopenDM(thread: threadID)
+ if case .ok = r { setDMClosedLocally(threadID, false) }
+ return r
+ }
+
+ /// A live dm_closed/dm_reopened frame โ€” either party acted, possibly on
+ /// another device. Fold the flag straight into the row like presence (no
+ /// refetch; the frame carries the whole change); closing also hides, the
+ /// web's WS handler rule.
+ public func applyDMClosed(threadID: String, closed: Bool) {
+ guard let i = threads.firstIndex(where: { $0.id == threadID }) else { return }
+ threads[i].dmClosed = closed
+ if closed { threads[i].hidden = true }
+ }
+
+ // The shared local fold for our OWN close/reopen: row flag + hidden move
+ // together (ending hides for both sides; reopening restores the row), and
+ // the parked feed model learns immediately so an open pane swaps its
+ // composer/banner without waiting for the echo frame.
+ private func setDMClosedLocally(_ threadID: String, _ closed: Bool) {
+ if let i = threads.firstIndex(where: { $0.id == threadID }) {
+ threads[i].dmClosed = closed
+ threads[i].hidden = closed
+ }
+ NativeFeedModelCache.shared.noteDMClosed(instance: instance.id,
+ thread: threadID, closed: closed)
+ }
+
+ /// The DM-privacy door for the settings picker ("" / "everyone" /
+ /// "nobody"; nil = couldn't load โ€” keep the picker gated, like Away).
+ public func loadDMPrivacy() async -> String? {
+ await api?.dmPrivacy()
+ }
+
+ /// Set who may start a DM with you โ€” "everyone" or "nobody".
+ @discardableResult
+ public func setDMPrivacy(_ value: String) async -> Bool {
+ await api?.setDMPrivacy(value) ?? false
+ }
+
// Start a chat with the given other members (empty = a "Notes" self thread, one =
// DM, more = group). Generates a fresh thread key, wraps it to every member
// MARK: Open groups (community)
@@ -1116,8 +1179,17 @@ public final class ThreadStore: ObservableObject {
return nil
}
- guard let tid = await api.createThread(kind: kind, nameBlob: built.nameBlob,
- members: built.members, openKey: built.openKey) else {
+ let tid: String
+ switch await api.createThread(kind: kind, nameBlob: built.nameBlob,
+ members: built.members, openKey: built.openKey) {
+ case .success(let id):
+ tid = id
+ case .failure(.dmsClosed):
+ // Their DM door is shut (issue #578) โ€” honest copy, inline where
+ // the picker already shows loadError, not a generic failure.
+ loadError = "They're not accepting messages"
+ return nil
+ case .failure:
loadError = "Couldn't start the chat. Try again."
return nil
}
@@ -1331,9 +1403,9 @@ public final class ThreadStore: ObservableObject {
return (nameBlob, wrapped)
}.value
guard let built,
- let tid = await api.createThread(kind: "self", nameBlob: built.nameBlob,
- members: [["user_id": myUserId,
- "wrapped": built.wrapped]]) else {
+ case .success(let tid) = await api.createThread(
+ kind: "self", nameBlob: built.nameBlob,
+ members: [["user_id": myUserId, "wrapped": built.wrapped]]) else {
return nil
}
await refresh()
diff --git a/ios/LChatMac/MacSettingsView.swift b/ios/LChatMac/MacSettingsView.swift
index a8683c2..2a0a694 100644
--- a/ios/LChatMac/MacSettingsView.swift
+++ b/ios/LChatMac/MacSettingsView.swift
@@ -18,6 +18,13 @@ struct MacSettingsView: View {
@State private var savedAway = false // last server-confirmed value โ€” makes onChange idempotent (load, revert)
@State private var awayLoaded = false // gate the toggle until /api/me answers
@State private var awayNote: String?
+ // DM privacy (#578): who may START a direct message with you. Same
+ // load/save discipline as Away โ€” gated until /api/me answers, saved on
+ // change, reverted with an inline note if the save doesn't stick.
+ @State private var dmPrivacy = "everyone"
+ @State private var savedDMPrivacy = "everyone"
+ @State private var dmPrivacyLoaded = false
+ @State private var dmPrivacyNote: String?
@State private var hasPaidLevels = false // this space sells (or I'm subscribed to) an access level
@State private var showMembership = false
@@ -87,6 +94,18 @@ struct MacSettingsView: View {
if let awayNote {
Text(awayNote).font(.footnote).foregroundStyle(.secondary)
}
+ // DM privacy (#578): your DM door, per server โ€” saved on change.
+ Picker("Who can message you directly", selection: $dmPrivacy) {
+ Text("Everyone in this space").tag("everyone")
+ Text("No one").tag("nobody")
+ }
+ .disabled(!dmPrivacyLoaded)
+ .onChange(of: dmPrivacy) { _, next in setDMPrivacy(next) }
+ Text("Existing chats keep working. Admins and helper bots can always reach you.")
+ .font(.footnote).foregroundStyle(.secondary)
+ if let dmPrivacyNote {
+ Text(dmPrivacyNote).font(.footnote).foregroundStyle(.secondary)
+ }
}
if hasPaidLevels {
Section("Membership") {
@@ -114,12 +133,14 @@ struct MacSettingsView: View {
selectedInstanceID = instance?.id
name = instance?.screenName ?? ""
loadAway()
+ loadDMPrivacy()
loadPaidLevels()
}
.onChange(of: selectedInstanceID) { _, _ in
name = instance?.screenName ?? ""
note = nil
loadAway()
+ loadDMPrivacy()
loadPaidLevels()
}
}
@@ -161,6 +182,35 @@ struct MacSettingsView: View {
}
}
+ private func loadDMPrivacy() {
+ dmPrivacyLoaded = false
+ dmPrivacyNote = nil
+ Task {
+ if let current = await api?.dmPrivacy() {
+ // The server stores "everyone" as "" โ€” normalise for the tags.
+ let v = current == "nobody" ? "nobody" : "everyone"
+ savedDMPrivacy = v
+ dmPrivacy = v
+ dmPrivacyLoaded = true
+ } else {
+ dmPrivacyNote = "Couldn't load this setting."
+ }
+ }
+ }
+
+ private func setDMPrivacy(_ next: String) {
+ guard dmPrivacyLoaded, next != savedDMPrivacy else { return } // skip programmatic sets
+ dmPrivacyNote = nil
+ Task {
+ if await api?.setDMPrivacy(next) == true {
+ savedDMPrivacy = next
+ } else {
+ dmPrivacy = savedDMPrivacy // didn't stick โ€” put it back, say so inline
+ dmPrivacyNote = "Couldn't save โ€” try again."
+ }
+ }
+ }
+
// The slider moves by stop index; the pref stores the point size itself
// (snap() forgives any stored off-ladder value).
private var textSizeIndex: Binding<Double> {
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 213b3fb..199b4d3 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -177,6 +177,27 @@ struct NativeChatView: View {
.sheet(item: $membersSheetThread) { t in
MembersSheet(store: store, thread: t)
}
+ // End conversation (#578): the destructive confirm behind the menu
+ // item โ€” ending is mutual, so it never fires on a bare click.
+ .confirmationDialog("End this conversation?",
+ isPresented: Binding(
+ get: { endConversationTarget != nil },
+ set: { if !$0 { endConversationTarget = nil } }),
+ titleVisibility: .visible,
+ presenting: endConversationTarget) { t in
+ Button("End Conversation", role: .destructive) {
+ Task {
+ // Optimistic in the store; a refused POST reverts. Ending
+ // hides the chat, so move the selection off it (the Close
+ // item's rule).
+ if await store.endConversation(t), selectedID == t.id {
+ selectedID = store.threads.first(where: { !$0.hidden && $0.id != t.id })?.id
+ }
+ }
+ }
+ } message: { _ in
+ Text("It closes for both of you, and no new messages can be sent unless someone reopens it.")
+ }
.sheet(item: $headerProfile) { target in
MemberProfileSheet(store: store, uid: target.id) { e in
ThreadAvatar(title: store.displayName(for: e),
@@ -517,6 +538,12 @@ struct NativeChatView: View {
s.roster.first { $0.user_id == uid }.flatMap(s.avatarSource(for:))
}
},
+ // The closed banner's Reopen (#578) goes through
+ // the store so the sidebar row un-hides in the
+ // same beat as the composer's return.
+ onReopenDM: { [weak store] in
+ await store?.reopenDM(thread.id) ?? .failed
+ },
// Mute/unmute a sender (#578) โ€” the feed reads
// the muted set off its model; writes go through
// the store's sealed-prefs save.
@@ -631,8 +658,17 @@ struct NativeChatView: View {
}
}
}
+ // End conversation (#578): DM-only, stronger than Close โ€” mutual and
+ // server-enforced, hence the confirm. Once ended, the closed banner's
+ // Reopen is the way back, so the item hides on an ended DM too.
+ if t.kind == "dm", !t.dmClosed {
+ Button("End Conversationโ€ฆ", role: .destructive) { endConversationTarget = t }
+ }
}
+ // The End-conversation confirm's target thread (#578) โ€” nil = no dialog.
+ @State private var endConversationTarget: ChatThread?
+
// ---- companion apps: native chat beside the app's web interface --------
// The companion split (web: Chat | Split | Interface): the CHAT half is
@@ -1355,6 +1391,13 @@ struct NativeChatView: View {
store.applyReadFrame(threadID: tid, lastReadAt: at)
return
}
+ // A DM ended or revived (#578), by either side, maybe elsewhere โ€”
+ // fold the flag in place like presence; the frame is the change.
+ if frame.type == "dm_closed" || frame.type == "dm_reopened",
+ let tid = frame.thread_id {
+ store.applyDMClosed(threadID: tid, closed: frame.type == "dm_closed")
+ return
+ }
// Any stored-message mutation anywhere in the instance refreshes
// the sidebar (previews, ordering, unread) โ€” debounced so a burst
// is one refresh.
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 2682fff..8acdeb3 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -105,6 +105,7 @@ struct NativeFeedView: View {
isActive: Bool = true, roster: [RosterEntry],
instance: SavedInstance? = nil, instanceKeyB64: String? = nil,
mentionAvatar: @escaping (String) -> AvatarSource?,
+ onReopenDM: (() async -> LChatAPI.ReopenDM)? = nil,
onMutePerson: ((String, Bool) -> Void)? = nil) {
// The model arrives PARKED (NativeFeedModelCache) โ€” folded messages and
// a live socket survive leaving the chat, so re-opening never flashes.
@@ -136,6 +137,7 @@ struct NativeFeedView: View {
self.thread = thread
self.instance = instance
self.instanceKeyB64 = instanceKeyB64
+ self.onReopenDM = onReopenDM
self.onMutePerson = onMutePerson
}
@@ -146,6 +148,10 @@ struct NativeFeedView: View {
private let thread: ChatThread
private let instance: SavedInstance?
private let instanceKeyB64: String?
+ // The closed banner's Reopen (#578) โ€” routed through the mounting view's
+ // ThreadStore so the sidebar row updates with the composer. nil (a mount
+ // without a store, e.g. the Debug pane) just hides the button's action.
+ private let onReopenDM: (() async -> LChatAPI.ReopenDM)?
// 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)?
@@ -465,11 +471,19 @@ struct NativeFeedView: View {
}
}
.safeAreaInset(edge: .bottom) {
- // The material band lives on the input row INSIDE `composer`, never
- // on this whole inset: staged previews/banners float transparent
- // above it, so the band can't grow and curtain the chat (the
- // failure that got the original full-inset .bar slab removed).
- composer
+ // An ended DM (#578): the server refuses new messages, so the
+ // composer gives way to the closed banner โ€” the one honest action
+ // left is Reopen.
+ if model.dmClosed {
+ dmClosedBanner
+ } else {
+ // The material band lives on the input row INSIDE `composer`,
+ // never on this whole inset: staged previews/banners float
+ // transparent above it, so the band can't grow and curtain the
+ // chat (the failure that got the original full-inset .bar slab
+ // removed).
+ composer
+ }
}
// Tamper-evidence failure banner (docs/tamper-evidence.md) โ€” only when
// this space fails an integrity check; "What's this?" fills the report
@@ -802,6 +816,44 @@ struct NativeFeedView: View {
@State private var taItems: [TypeaheadItem] = []
@State private var taIndex = 0
+ // The ended-DM banner that takes the composer's place (#578): inline in
+ // the composer's layout slot โ€” never floating over the feed. A refused
+ // reopen swaps the line for its reason (their DM door / a blip), the
+ // web's dm-closed-bar behaviour.
+ @State private var reopenBusy = false
+ @State private var reopenNote: String?
+
+ private var dmClosedBanner: some View {
+ HStack(spacing: 10) {
+ Text(reopenNote ?? "This conversation is closed.")
+ .font(.system(size: 13))
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ Button("Reopen") {
+ guard let onReopenDM else { return }
+ reopenNote = nil
+ reopenBusy = true
+ Task {
+ switch await onReopenDM() {
+ case .ok:
+ break // the flag flips (store + model); the composer returns
+ case .dmsClosed:
+ reopenNote = "They're not accepting direct messages right now."
+ case .failed:
+ reopenNote = "Couldn't reopen โ€” try again."
+ }
+ reopenBusy = false
+ }
+ }
+ .disabled(reopenBusy || onReopenDM == nil)
+ .accessibilityHint("Reopens this conversation for both of you")
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .frame(maxWidth: .infinity)
+ .background(FrostedBand())
+ }
+
private var composer: some View {
VStack(alignment: .leading, spacing: 4) {
if !taItems.isEmpty, let token = taToken {
diff --git a/main.go b/main.go
index e4853e1..4138f38 100644
--- a/main.go
+++ b/main.go
@@ -444,6 +444,7 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("POST /api/nicknames", s.postNicknames)
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/group-description", s.postGroupDescription)
mux.HandleFunc("GET /api/threads", s.getThreads)
mux.HandleFunc("POST /api/threads", s.postThread)
@@ -460,6 +461,8 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("POST /api/threads/{id}/hidden", s.postThreadHidden)
mux.HandleFunc("POST /api/threads/{id}/pinned", s.postThreadPinned)
mux.HandleFunc("POST /api/threads/{id}/muted", s.postThreadMuted)
+ mux.HandleFunc("POST /api/threads/{id}/close-dm", s.postCloseDM)
+ mux.HandleFunc("POST /api/threads/{id}/reopen-dm", s.postReopenDM)
mux.HandleFunc("POST /api/threads/{id}/read", s.postThreadRead)
// Mini-apps (miniapps.go): scheduled "future messages" + per-thread state
@@ -893,6 +896,11 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusForbidden, "not a member of that thread")
return
}
+ // Same closed-DM gate as the WebSocket "msg" case (issue #578).
+ if closed, _ := s.db.dmClosed(body.ThreadID); closed {
+ httpError(w, http.StatusForbidden, "conversation closed")
+ return
+ }
m, err := s.db.insertMessage(body.ThreadID, body.Blob, userID)
if err != nil {
log.Println("insert message:", err)
@@ -1044,6 +1052,8 @@ func (s *Server) getMe(w http.ResponseWriter, r *http.Request) {
if me["role"] == "admin" {
me["sole_admin"] = s.db.adminCount() == 1
}
+ // DM privacy (issue #578): what the caller's own settings toggle reflects.
+ me["dm_privacy"] = s.db.userDMPrivacy(uid)
writeJSON(w, http.StatusOK, me)
}
@@ -1175,6 +1185,13 @@ func (s *Server) postThread(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"thread_id": existing, "existing": true})
return
}
+ // DM privacy (issue #578): a NEW DM respects the other person's door.
+ // An existing conversation (above) is untouched โ€” the setting governs
+ // opening contact, not history.
+ if !s.dmAllowed(uid, other) {
+ httpError(w, http.StatusForbidden, "dms closed")
+ return
+ }
}
id, err := s.db.createThreadVis(body.Kind, body.NameBlob, body.Members, body.Visibility, body.OpenKey, body.OpenTier)
if err != nil {
@@ -2366,6 +2383,152 @@ func (s *Server) postThreadPinned(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+// dmAllowed says whether `from` may open a NEW DM with `to`. Admins and bots
+// pass a closed door โ€” the space's operator must stay reachable, and
+// 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" {
+ return true
+ }
+ return s.db.userRole(from) == "admin" || s.db.isAgent(from)
+}
+
+// postDMPrivacy sets the caller's own DM door: "" / "everyone" (anyone in the
+// space may open a DM with them) or "nobody". Enforced at DM creation โ€” see
+// dmAllowed.
+func (s *Server) postDMPrivacy(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ DMPrivacy string `json:"dm_privacy"`
+ }
+ 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
+ }
+ v := body.DMPrivacy
+ if v == "everyone" {
+ v = "" // the default, stored lean
+ }
+ if v != "" && v != "nobody" {
+ httpError(w, http.StatusBadRequest, "bad dm_privacy")
+ return
+ }
+ if err := s.db.setUserDMPrivacy(uid, v); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "dm_privacy": v})
+}
+
+// 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
+// (which is about the person): closing is about this conversation.
+func (s *Server) postCloseDM(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
+ }
+ threadID := r.PathValue("id")
+ if !s.db.threadMember(threadID, uid) {
+ httpError(w, http.StatusForbidden, "not a thread member")
+ return
+ }
+ if s.db.threadKind(threadID) != "dm" {
+ httpError(w, http.StatusBadRequest, "not a dm")
+ return
+ }
+ if err := s.db.closeDM(threadID, uid); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ // Drop it from both lists (per-user hidden markers), and tell both sides'
+ // live clients so open panes swap to the closed banner at once.
+ members, _ := s.db.memberUserIDs(threadID)
+ set := map[string]bool{}
+ for _, m := range members {
+ _ = s.db.setThreadHidden(m, threadID, true)
+ set[m] = true
+ }
+ out, _ := json.Marshal(map[string]any{"type": "dm_closed", "thread_id": threadID})
+ s.hub.broadcast <- outbound{members: set, data: out}
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// postReopenDM reopens a closed DM. Either side may โ€” but through the other
+// person's DM door: if they've since closed DMs, reopening follows their
+// setting (the same dmAllowed gate as opening a fresh DM).
+func (s *Server) postReopenDM(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
+ }
+ threadID := r.PathValue("id")
+ if !s.db.threadMember(threadID, uid) {
+ httpError(w, http.StatusForbidden, "not a thread member")
+ return
+ }
+ closed, _ := s.db.dmClosed(threadID)
+ if !closed {
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true}) // already open
+ return
+ }
+ other, _ := s.db.otherThreadMember(threadID, uid)
+ if other != "" && !s.dmAllowed(uid, other) {
+ httpError(w, http.StatusForbidden, "dms closed")
+ return
+ }
+ if err := s.db.reopenDM(threadID); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ // The reopener sees it again now; the other side stays hidden until the
+ // first new message unhides it (insertMessage's clearThreadHidden).
+ _ = s.db.setThreadHidden(uid, threadID, false)
+ members, _ := s.db.memberUserIDs(threadID)
+ set := map[string]bool{}
+ for _, m := range members {
+ set[m] = true
+ }
+ out, _ := json.Marshal(map[string]any{"type": "dm_reopened", "thread_id": threadID})
+ s.hub.broadcast <- outbound{members: set, data: out}
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
// postThreadMuted mutes (muted=true) or unmutes (muted=false) a chat for the
// caller โ€” a per-user marker that syncs across their devices. While set, the
// push fan-out skips every one of their devices for this thread; the muted
diff --git a/miniapps.go b/miniapps.go
index 8973d2d..14f53e4 100644
--- a/miniapps.go
+++ b/miniapps.go
@@ -278,6 +278,13 @@ func (s *Server) sweepDueScheduled() int {
}
n := 0
for _, sm := range due {
+ // A DM closed since this was scheduled (issue #578): the conversation
+ // ended, so the queued message dies with it rather than forcing the
+ // thread back open.
+ if closed, _ := s.db.dmClosed(sm.ThreadID); closed {
+ _ = s.db.deleteScheduledMessage(sm.ID)
+ continue
+ }
m, ok, err := s.db.deliverScheduledMessage(sm)
if err != nil {
log.Println("schedule: deliver:", err)
diff --git a/web/pages/index.html b/web/pages/index.html
index 65dee24..d083d27 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -198,6 +198,7 @@
<button class="menu-item" id="thread-pin" role="menuitem">Pin chat</button>
<button class="menu-item" id="thread-mute" role="menuitem">Mute chat</button>
<button class="menu-item" id="thread-close" role="menuitem">Close chat</button>
+ <button class="menu-item danger" id="thread-end" role="menuitem">End conversation</button>
<button class="menu-item danger" id="thread-leave" role="menuitem">Leave group</button>
</div>
</div>
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index cef6f01..ce75fad 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -621,6 +621,12 @@ export const app = defineApp({
// renderer, so /poll shows the same card in the same place.
renderSurfaces(ctx.threadId, root.querySelector(":scope > .app-surface"));
+ // A closed DM (issue #578): the server refuses new traffic, so the
+ // composer gives way to an honest banner with the one useful action โ€”
+ // Reopen. Re-evaluated every render, so the dm_closed/dm_reopened frames
+ // flip the pane live.
+ paintClosedState(root, ctx);
+
// Let addLocalNotice re-render this pane when a command reply comes in โ€” the
// reply isn't folded state, so nothing else would. Points at the live mount
// (like the hotkeys/native re-points above), self-removed on teardown.
@@ -798,6 +804,46 @@ function mutedRev(m, ctx) {
return revealedMuted.has(m.sid) ? "|m1" : "|m0";
}
+// Swap the composer for the closed-conversation banner while the host says
+// this DM is closed (issue #578). The banner occupies the composer's layout
+// slot โ€” inline, never floating over the feed.
+function paintClosedState(root, ctx) {
+ const closed = !!(host && host.dmClosed && host.dmClosed(ctx.threadId));
+ const compose = root.querySelector(".composer");
+ const float = root.querySelector(".composer-float");
+ let bar = root.querySelector(":scope > .dm-closed-bar");
+ if (!closed) {
+ if (bar) bar.remove();
+ if (compose) compose.classList.remove("hidden");
+ if (float) float.classList.remove("hidden");
+ return;
+ }
+ if (compose) compose.classList.add("hidden");
+ if (float) float.classList.add("hidden");
+ if (bar) return; // already painted; keep any inline error it shows
+ bar = document.createElement("div");
+ bar.className = "dm-closed-bar";
+ const text = document.createElement("span");
+ text.textContent = "This conversation is closed.";
+ bar.appendChild(text);
+ const reopen = document.createElement("button");
+ reopen.type = "button";
+ reopen.textContent = "Reopen";
+ reopen.onclick = async () => {
+ reopen.disabled = true;
+ const r = host && host.reopenDM ? await host.reopenDM(ctx.threadId) : { ok: false };
+ if (r && r.ok) return; // the host repaints; the banner unmounts itself
+ reopen.disabled = false;
+ // "dms closed" = their DM door (issue #578); anything else is transient.
+ text.textContent =
+ r && String(r.error).includes("dms closed")
+ ? "They're not accepting direct messages right now."
+ : "Couldn't reopen โ€” try again.";
+ };
+ bar.appendChild(reopen);
+ root.appendChild(bar);
+}
+
// ---- bubbles: the original feed's exact markup ------------------------------
// renderItem builds one feed row inside a display:contents wrapper, so the
diff --git a/web/static/shell.js b/web/static/shell.js
index 34a4611..6ddcddf 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -892,6 +892,7 @@ const readMarks = {};
const hidden = {}; // thread id -> bool: closed (hidden from the list), synced from the server
const pinned = {}; // thread id -> bool: pinned to the top of the list, synced from the server
const muted = {}; // thread id -> bool: muted (no notifications), synced from the server
+const dmClosed = {}; // thread id -> bool: DM ended by either party (server-enforced)
let chatFilter = ""; // lowercased substring filtering the thread list (search box)
// The funnel menu beside search: online-only plus category checkboxes. All
// categories on + online off = the unfiltered default (funnel unhighlighted).
@@ -2290,6 +2291,7 @@ async function applyThreads(r) {
hidden[t.id] = !!t.hidden; // closed chats (server-synced) are dropped from the list
pinned[t.id] = !!t.pinned; // pinned chats (server-synced) float to the top
muted[t.id] = !!t.muted; // muted chats (server-synced) stay quiet
+ dmClosed[t.id] = !!t.dm_closed; // ended DMs show the closed banner, not a composer
// The server's watermark can trail a POST we just made โ€” never regress ours.
readMarks[t.id] = Math.max(readMarks[t.id] || 0, t.last_read_at || 0);
if (t.last_blob && threadKeys[t.id]) {
@@ -2855,6 +2857,10 @@ function renderApp() {
closeTMenu();
closeChat();
};
+ document.getElementById("thread-end").onclick = () => {
+ closeTMenu();
+ endConversation();
+ };
document.getElementById("thread-leave").onclick = () => {
closeTMenu();
leaveGroup();
@@ -3365,6 +3371,9 @@ function paintThreadHeader(id) {
}
document.getElementById("thread-pin").textContent = pinned[id] ? "Unpin chat" : "Pin chat";
document.getElementById("thread-mute").textContent = muted[id] ? "Unmute chat" : "Mute chat";
+ // End conversation is a DM-only act (issue #578) โ€” and once ended, the
+ // banner's Reopen is the way back, so the item hides on a closed DM too.
+ document.getElementById("thread-end").style.display = t && t.kind === "dm" && !dmClosed[id] ? "" : "none";
paintViewToggle(id);
paintHeaderAvatar(id);
}
@@ -4586,6 +4595,29 @@ 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),
+ // 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],
+ reopenDM: async (threadId) => {
+ try {
+ const r = await fetch(`/api/threads/${encodeURIComponent(threadId)}/reopen-dm`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token }),
+ });
+ if (r.ok) {
+ dmClosed[threadId] = false;
+ hidden[threadId] = false;
+ renderThreadList();
+ renderActiveSurfaces(); // swap the banner back to the composer
+ return { ok: true };
+ }
+ const body = await r.json().catch(() => ({}));
+ return { ok: false, error: body.error || "server" };
+ } catch {
+ return { ok: false, error: "network" };
+ }
+ },
});
// The lab app's native-composer handlers (registered via the bridge when the
@@ -7554,6 +7586,16 @@ function connectWS() {
refreshAvatars();
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.
+ const tid = msg.thread_id;
+ dmClosed[tid] = msg.type === "dm_closed";
+ if (msg.type === "dm_closed") hidden[tid] = true;
+ renderThreadList();
+ if (activeId === tid) renderActiveSurfaces();
+ return;
+ }
if (msg.type === "read") {
// Our own read watermark advanced on another of this user's devices (the
// server routes this frame only to the owner's connections) โ€” clear the
@@ -8223,6 +8265,9 @@ async function openOrCreateDM(uid) {
renderThreadList();
openThread(id);
} catch (e) {
+ // Their DM door is closed (issue #578) โ€” tell the caller so the button
+ // that asked can say so inline instead of failing silently.
+ if (String(e && e.message).includes("dms closed")) return false;
console.error(e);
}
}
@@ -8444,9 +8489,16 @@ function viewProfile(uid) {
row.style.cssText = "justify-content:center;margin-top:14px";
const msg = document.createElement("button");
msg.textContent = `Message ${displayNameFor(nicknames[uid], e.label) || "them"}`;
- msg.onclick = () => {
+ msg.onclick = async () => {
+ // Their DM door may be closed (issue #578) โ€” say so on the button
+ // itself instead of closing the card over a silent failure.
+ msg.disabled = true;
+ const refused = (await openOrCreateDM(uid)) === false;
+ if (refused) {
+ msg.textContent = "They're not accepting messages";
+ return;
+ }
backdrop.remove();
- openOrCreateDM(uid);
};
row.appendChild(msg);
card.appendChild(row);
@@ -8692,6 +8744,36 @@ async function closeChat() {
showList();
}
+// End the open DM for both people (issue #578): it drops off both lists and
+// the server refuses new messages until someone reopens it. Stronger than
+// Close chat (which quietly returns on activity) and explicitly mutual โ€” hence
+// the confirm.
+async function endConversation() {
+ const id = activeId;
+ const t = threads.find((x) => x.id === id);
+ if (!t || t.kind !== "dm") return;
+ if (!confirm("End this conversation? It closes for both of you, and no new messages can be sent unless someone reopens it.")) return;
+ try {
+ const r = await fetch(`/api/threads/${encodeURIComponent(id)}/close-dm`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token }),
+ });
+ if (!r.ok) throw new Error("server");
+ } catch {
+ return; // nothing changed server-side; leave the chat as it was
+ }
+ dmClosed[id] = true;
+ hidden[id] = true;
+ stashFeed(id);
+ activeId = null;
+ localStorage.removeItem(ACTIVE_KEY);
+ renderThreadList();
+ document.getElementById("thread-view").classList.add("hidden");
+ document.getElementById("thread-empty").classList.remove("hidden");
+ showList();
+}
+
// Mute/unmute the open chat: no notifications and no unread dot while muted;
// messages still arrive normally. Per-user, synced across your devices โ€” the
// server just skips pushing to you for this thread.
@@ -9891,7 +9973,9 @@ async function createThread(otherIds, opts = {}) {
body: JSON.stringify(body),
})
).json();
- if (!r.thread_id) throw new Error("no thread id");
+ // Surface the server's refusal reason ("dms closed" โ€” the person's DM door,
+ // issue #578) so callers can answer with honest copy, not a generic failure.
+ if (!r.thread_id) throw new Error(r.error || "no thread id");
// The server dedupes DMs: if one already exists it returns it with existing:true.
// Keep that thread's real key โ€” don't overwrite it with the throwaway key we
// generated for the would-be new thread (which no member could decrypt).
@@ -10350,6 +10434,54 @@ function editProfile() {
card.appendChild(aboutLabel);
card.appendChild(about);
+ // DM privacy (issue #578): who may START a direct message with you. Honest
+ // about the carve-out โ€” admins and bots still can (the operator must stay
+ // reachable; Eleven's welcome DM is how onboarding speaks). Saved on change,
+ // independent of the Save button, like a settings switch.
+ const dmLabel = document.createElement("label");
+ dmLabel.className = "field-label";
+ dmLabel.style.marginTop = "14px";
+ dmLabel.textContent = "Who can message you directly";
+ dmLabel.htmlFor = "me-dm-privacy";
+ const dmSel = document.createElement("select");
+ dmSel.id = "me-dm-privacy";
+ for (const [val, text] of [
+ ["everyone", "Everyone in this space"],
+ ["nobody", "No one"],
+ ]) {
+ const o = document.createElement("option");
+ o.value = val;
+ o.textContent = text;
+ dmSel.appendChild(o);
+ }
+ const dmNote = document.createElement("div");
+ dmNote.className = "field-note";
+ dmNote.textContent = "Existing chats keep working. Admins and helper bots can always reach you.";
+ fetch(`/api/me?token=${encodeURIComponent(session.token)}`)
+ .then((r) => r.json())
+ .then((m) => {
+ dmSel.value = m.dm_privacy === "nobody" ? "nobody" : "everyone";
+ })
+ .catch(() => {});
+ dmSel.onchange = async () => {
+ dmSel.disabled = true;
+ try {
+ const r = await fetch("/api/dm-privacy", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, dm_privacy: dmSel.value }),
+ });
+ if (!r.ok) throw new Error("server");
+ } catch {
+ dmNote.textContent = "Couldn't save โ€” try again.";
+ } finally {
+ dmSel.disabled = false;
+ }
+ };
+ card.appendChild(dmLabel);
+ card.appendChild(dmSel);
+ card.appendChild(dmNote);
+
// Your access levels โ€” read-only here (an admin, or another member of the
// level, grants them; docs/community-and-tiers.md). The self-facing mirror
// of the chips on someone else's profile card; fetched fresh, hidden when
diff --git a/web/static/style.css b/web/static/style.css
index 6034d7a..1a273ee 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -2191,6 +2191,20 @@ input.filter-check:focus-visible { outline: 2px solid var(--accent); outline-off
}
.muted-stub-show:hover { text-decoration: underline; }
.muted-stub-show:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+/* A closed DM's banner (issue #578): sits where the composer was โ€” inline,
+ occupying layout space, never floating over the feed. */
+.dm-closed-bar {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 14px 16px;
+ border-top: 1px solid var(--border);
+ color: var(--muted);
+ font-size: 14px;
+}
+/* Small helper line under a settings field (e.g. the DM-privacy select). */
+.field-note { margin-top: 6px; font-size: 12px; color: var(--muted); }
.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