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>
23 files changed,
+1088
−31
changelog/2026-07-12-dm-privacy.md+5 −0db.go+59 −1dmprivacy_test.go+189 −0hub.go+8 −0ios/LChat/Views/NativeChatScreen.swift+76 −8ios/LChat/Views/ServerSettingsSheet.swift+46 −0ios/LChat/Views/ThreadListView.swift+7 −0ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+19 −0ios/LChatCore/Sources/LChatCore/ChatSocket.swift+2 −1ios/LChatCore/Sources/LChatCore/LChatAPI.swift+65 −5ios/LChatCore/Sources/LChatCore/Models.swift+2 −0ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift+11 −0ios/LChatCore/Sources/LChatCore/SpaceCreate.swift+6 −3ios/LChatCore/Sources/LChatCore/ThreadStore.swift+77 −5ios/LChatMac/MacSettingsView.swift+50 −0ios/LChatMac/NativeChatView.swift+43 −0ios/LChatMac/NativeFeedView.swift+57 −5main.go+163 −0miniapps.go+7 −0web/pages/index.html+1 −0web/static/apps/chat.js+46 −0web/static/shell.js+135 −3web/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 |