eleven / messenger Public

Moderation 1/6: mute a chat, mute a person (#578) (#754)

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

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

* 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 b2e74bcff3fc6b338f262acc1be4c3bf6a0f2d61 parent 200928b
28 files changed, +1268 −69
  • changelog/2026-07-12-mute.md +6 −0
  • db.go +85 −1
  • ios/LChat/Views/NativeChatScreen.swift +52 −14
  • ios/LChat/Views/ThreadListCollection.swift +18 −2
  • ios/LChat/Views/ThreadListView.swift +2 −1
  • ios/LChat/Views/ThreadRow.swift +9 −1
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +19 −0
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +41 −0
  • ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift +14 −0
  • ios/LChatCore/Sources/LChatCore/MessageBubble.swift +59 −1
  • ios/LChatCore/Sources/LChatCore/MessageRightClick.swift +3 −0
  • ios/LChatCore/Sources/LChatCore/ModPrefs.swift +32 −0
  • ios/LChatCore/Sources/LChatCore/Models.swift +7 −0
  • ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift +14 −0
  • ios/LChatCore/Sources/LChatCore/SharedThreadKeys.swift +77 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +176 −3
  • ios/LChatMac/NativeChatView.swift +15 −1
  • ios/LChatMac/NativeFeedView.swift +86 −38
  • ios/LChatNSE/NotificationService.swift +22 −3
  • main.go +100 −0
  • miniapps.go +1 −0
  • mute_test.go +149 −0
  • push.go +15 −0
  • web/pages/index.html +1 −0
  • web/static/apps/chat.js +58 −2
  • web/static/shell.js +172 −2
  • web/static/style.css +27 −0
  • web/static/sw.js +8 −0
diff --git a/changelog/2026-07-12-mute.md b/changelog/2026-07-12-mute.md
new file mode 100644
index 0000000..9062391
--- /dev/null
+++ b/changelog/2026-07-12-mute.md
@@ -0,0 +1,6 @@
+🤖 You can now mute a chat or a person. Mute a chat from its ⋮ menu — no
+notifications, no unread dot, and messages keep arriving quietly. Mute a
+person from their profile card or a long-press on their message — their
+messages fold into a small "Muted message" stub you can tap to read, and they
+never notify you. Muting is private: the other person isn't told, and who
+you've muted is sealed on your devices — the server can't read it either.
diff --git a/db.go b/db.go
index d7e8808..3ed87b0 100644
--- a/db.go
+++ b/db.go
@@ -394,6 +394,15 @@ CREATE TABLE IF NOT EXISTS pinned_threads (
thread_id TEXT NOT NULL,
PRIMARY KEY (user_id, thread_id)
);
+-- per-user muted chats: threads that user gets no notifications for (synced
+-- across their devices). The server consults it only to SKIP push sends — the
+-- same routing-preference class as hidden/pinned, never content. Other members
+-- never see it.
+CREATE TABLE IF NOT EXISTS muted_threads (
+ user_id TEXT NOT NULL,
+ thread_id TEXT NOT NULL,
+ PRIMARY KEY (user_id, thread_id)
+);
-- 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.
@@ -733,6 +742,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_checkpoints_seq_from ON checkpoints(seq_fr
// the instant path. Stores only (thread, target id, deleted-at):
// metadata the server transiently knew when it performed the delete.
"ALTER TABLE messages ADD COLUMN tombstone_of INTEGER",
+ // A member's private moderation preferences (muted people, for now),
+ // sealed to their OWN public key like nicknames_blob — the server can't
+ // 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",
} {
if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -1133,6 +1147,39 @@ func (db *DB) userNicknamesBlob(userID string) (string, int64, error) {
return blob, version, err
}
+// setUserPrefsBlob stores the member's private moderation preferences (muted
+// people, for now), sealed to their OWN public key like the nicknames blob —
+// only their devices can open it ("" clears it). Same optimistic versioning:
+// a stale base writes nothing (ok=false, current version returned); < 0 skips
+// the check.
+func (db *DB) setUserPrefsBlob(userID, blob string, baseVersion int64) (int64, bool, error) {
+ q := "UPDATE users SET prefs_blob = ?, prefs_version = COALESCE(prefs_version, 0) + 1 WHERE id = ? AND COALESCE(is_agent, 0) = 0"
+ args := []any{blob, userID}
+ if baseVersion >= 0 {
+ q += " AND COALESCE(prefs_version, 0) = ?"
+ args = append(args, baseVersion)
+ }
+ res, err := db.sql.Exec(q, args...)
+ if err != nil {
+ return 0, false, err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return 0, false, err
+ }
+ _, v, verr := db.userPrefsBlob(userID) // where the row (or the winner) is now
+ return v, n > 0, verr
+}
+
+// userPrefsBlob returns the member's own sealed moderation preferences (""
+// when never set) and the blob's version (0 until first write).
+func (db *DB) userPrefsBlob(userID string) (string, int64, error) {
+ var blob string
+ var version int64
+ err := db.sql.QueryRow("SELECT COALESCE(prefs_blob, ''), COALESCE(prefs_version, 0) FROM users WHERE id = ?", userID).Scan(&blob, &version)
+ return blob, version, err
+}
+
func (db *DB) userExists(id string) bool {
var x int
return db.sql.QueryRow("SELECT 1 FROM users WHERE id = ?", id).Scan(&x) == nil
@@ -2508,6 +2555,9 @@ type Thread struct {
// Pinned is true when the requesting user keeps this chat at the top of
// their list.
Pinned bool `json:"pinned,omitempty"`
+ // 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"`
// 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"`
@@ -2673,6 +2723,7 @@ func (db *DB) threadsForUser(userID string) ([]Thread, error) {
COALESCE(t.visibility, 'invite'), COALESCE(t.open_key, ''), COALESCE(t.open_tier, ''), COALESCE(t.default_join, 0),
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),
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
@@ -2691,7 +2742,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.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.LastReadAt); err != nil {
return nil, err
}
if t.Visibility == "invite" {
@@ -2923,6 +2974,39 @@ func (db *DB) setThreadPinned(userID, threadID string, pinned bool) error {
return err
}
+// setThreadMuted mutes (muted=true) or unmutes (muted=false) a chat for one
+// user — a per-user marker, synced across their devices via getThreads. The
+// push fan-out consults it so a muted chat never buzzes any of their devices.
+func (db *DB) setThreadMuted(userID, threadID string, muted bool) error {
+ if muted {
+ _, err := db.sql.Exec(
+ "INSERT OR IGNORE INTO muted_threads(user_id, thread_id) VALUES(?, ?)", userID, threadID,
+ )
+ return err
+ }
+ _, err := db.sql.Exec("DELETE FROM muted_threads WHERE user_id = ? AND thread_id = ?", userID, threadID)
+ return err
+}
+
+// mutedUserIDs lists the users who have muted a thread, so push fan-out can
+// leave them out.
+func (db *DB) mutedUserIDs(threadID string) (map[string]bool, error) {
+ rows, err := db.sql.Query("SELECT user_id FROM muted_threads WHERE thread_id = ?", threadID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := map[string]bool{}
+ for rows.Next() {
+ var u string
+ if err := rows.Scan(&u); err != nil {
+ return nil, err
+ }
+ out[u] = true
+ }
+ return out, rows.Err()
+}
+
// setThreadRead advances one user's read watermark for a thread. Forward-only:
// a stale write from a lagging device can never wind the mark back and re-light
// unread dots the user's other devices already cleared. Reports whether the
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 1ee082b..1c42237 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -205,6 +205,9 @@ struct NativeChatScreen: View {
private var isUnread: Bool {
store.threads.first { $0.id == thread.id }?.unread ?? false
}
+ private var isMuted: Bool {
+ store.threads.first { $0.id == thread.id }?.isMuted ?? thread.isMuted
+ }
// The DM partner's uid — the header opens their profile card (rename them
// for yourself there, #577), like tapping the web header title.
@@ -363,6 +366,12 @@ struct NativeChatScreen: View {
Label(isPinned ? "Unpin chat" : "Pin chat",
systemImage: isPinned ? "pin.slash" : "pin")
}
+ Button {
+ store.setMuted(thread, !isMuted)
+ } label: {
+ Label(isMuted ? "Unmute chat" : "Mute chat",
+ systemImage: isMuted ? "bell" : "bell.slash")
+ }
Button {
isUnread ? store.markRead(thread) : store.markUnread(thread)
} label: {
@@ -788,6 +797,15 @@ struct NativeChatScreen: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 3)
.padding(.horizontal, 14)
+ } else if model.foldsAsMuted(m) {
+ // A muted member's message (#578): the quiet stub, revealed
+ // per-message for the session. Always a "them" row.
+ HStack(spacing: 0) {
+ MutedMessageStub { model.revealMuted(m.id) }
+ Spacer(minLength: 40)
+ }
+ .padding(EdgeInsets(top: startsRun ? 10 : 3, leading: 12,
+ bottom: 3, trailing: 12))
} else {
HStack(spacing: 0) {
if m.mine { Spacer(minLength: 40) }
@@ -807,13 +825,7 @@ struct NativeChatScreen: View {
onFileTap: { openFile($0) },
onVideoTap: { att, frame in playVideo(att, from: frame) },
downloading: blobDownload,
- actions: MessageActions(
- canEdit: model.canEdit(m),
- onReply: { startReply(m) },
- onEdit: { startEdit(m) },
- onDelete: { model.deleteMessage(m.id) },
- onCopy: { UIPasteboard.general.string = m.text },
- onReact: { model.toggleReaction(m.id, emoji: $0) }),
+ actions: messageActions(m),
unfurl: { url in
let card = await model.unfurl(url)
// The link card loads AFTER the send pin ran
@@ -906,7 +918,17 @@ struct NativeChatScreen: View {
row: { m, showSender in threadBubble(m, showSender: showSender) })
}
+ @ViewBuilder
private func threadBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> some View {
+ // The muted fold holds inside the thread view too (#578).
+ if model.foldsAsMuted(m) {
+ MutedMessageStub { model.revealMuted(m.id) }
+ } else {
+ threadMessageBubble(m, showSender: showSender)
+ }
+ }
+
+ private func threadMessageBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> some View {
MessageBubble(message: m, showSender: showSender,
senderName: model.senderName(m),
theme: .named(themeName),
@@ -921,17 +943,33 @@ struct NativeChatScreen: View {
onFileTap: { openFile($0) },
onVideoTap: { att, frame in playVideo(att, from: frame) },
downloading: blobDownload,
- actions: MessageActions(
- canEdit: model.canEdit(m),
- onReply: { startReply(m) },
- onEdit: { startEdit(m) },
- onDelete: { model.deleteMessage(m.id) },
- onCopy: { UIPasteboard.general.string = m.text },
- onReact: { model.toggleReaction(m.id, emoji: $0) }),
+ actions: messageActions(m),
unfurl: { await model.unfurl($0) })
.environment(\.chatFontSize, chatFontSize)
}
+ // One catalog for the feed and the thread view — reply/edit/delete/copy/
+ // react, plus Mute/Unmute-sender (#578) on others' uid-carrying messages
+ // (uid-less legacy messages have nothing to key a mute on).
+ private func messageActions(_ m: ChatFeedModel.Message) -> MessageActions {
+ var actions = MessageActions(
+ canEdit: model.canEdit(m),
+ onReply: { startReply(m) },
+ onEdit: { startEdit(m) },
+ onDelete: { model.deleteMessage(m.id) },
+ onCopy: { UIPasteboard.general.string = m.text },
+ onReact: { model.toggleReaction(m.id, emoji: $0) })
+ if !m.mine, let uid = m.raw.uid, !uid.isEmpty {
+ let muted = model.mutedPeople.contains(uid)
+ actions.senderMuted = muted
+ actions.senderLabel = model.senderName(m)
+ actions.onMutePerson = {
+ Task { await store.setPersonMuted(uid: uid, muted: !muted) }
+ }
+ }
+ return actions
+ }
+
private func openThread(_ rootID: Int64) {
guard rootID > 0, threadRootID != rootID else { return }
// Freeze the header stand-in NOW (web threadFallback) — the deepest
diff --git a/ios/LChat/Views/ThreadListCollection.swift b/ios/LChat/Views/ThreadListCollection.swift
index 530c4be..7ef2ef3 100644
--- a/ios/LChat/Views/ThreadListCollection.swift
+++ b/ios/LChat/Views/ThreadListCollection.swift
@@ -19,6 +19,7 @@ struct ThreadListCollection: UIViewControllerRepresentable {
var onClose: (ChatThread) -> Void
var onPin: (ChatThread) -> Void
var onToggleUnread: (ChatThread) -> Void
+ var onMute: (ChatThread) -> Void
func makeUIViewController(context: Context) -> ThreadListController { ThreadListController() }
@@ -27,6 +28,7 @@ struct ThreadListCollection: UIViewControllerRepresentable {
vc.onClose = onClose
vc.onPin = onPin
vc.onToggleUnread = onToggleUnread
+ vc.onMute = onMute
vc.apply(threads)
}
}
@@ -38,6 +40,7 @@ final class ThreadListController: UICollectionViewController {
var onClose: ((ChatThread) -> Void)?
var onPin: ((ChatThread) -> Void)?
var onToggleUnread: ((ChatThread) -> Void)?
+ var onMute: ((ChatThread) -> Void)?
private var threadsByID: [String: ChatThread] = [:]
private var latestThreads: [ChatThread] = []
@@ -62,7 +65,9 @@ final class ThreadListController: UICollectionViewController {
collectionView.backgroundColor = .systemBackground
var config = UICollectionLayoutListConfiguration(appearance: .plain)
- // Native swipe-to-close — the list mirror of the in-chat "Close chat".
+ // Native swipe-to-close — the list mirror of the in-chat "Close chat" —
+ // plus mute, Mail's trailing-cluster idiom (Close stays outermost, so
+ // a full swipe still closes).
config.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in
guard let self,
let id = self.dataSource.itemIdentifier(for: indexPath),
@@ -72,7 +77,14 @@ final class ThreadListController: UICollectionViewController {
done(true)
}
close.image = UIImage(systemName: "xmark.circle")
- return UISwipeActionsConfiguration(actions: [close])
+ let mute = UIContextualAction(style: .normal,
+ title: thread.isMuted ? "Unmute" : "Mute") { _, _, done in
+ self.onMute?(thread)
+ done(true)
+ }
+ mute.image = UIImage(systemName: thread.isMuted ? "bell" : "bell.slash")
+ mute.backgroundColor = .systemIndigo
+ return UISwipeActionsConfiguration(actions: [close, mute])
}
// Swipe right: toggle read/unread — Mail's idiom.
config.leadingSwipeActionsConfigurationProvider = { [weak self] indexPath in
@@ -182,6 +194,10 @@ final class ThreadListController: UICollectionViewController {
image: UIImage(systemName: thread.pinned ? "pin.slash" : "pin")) { [weak self] _ in
self?.onPin?(thread)
},
+ UIAction(title: thread.isMuted ? "Unmute chat" : "Mute chat",
+ image: UIImage(systemName: thread.isMuted ? "bell" : "bell.slash")) { [weak self] _ in
+ self?.onMute?(thread)
+ },
UIAction(title: thread.unread ? "Mark as Read" : "Mark as Unread",
image: UIImage(systemName: thread.unread ? "envelope.open" : "envelope.badge")) { [weak self] _ in
self?.onToggleUnread?(thread)
diff --git a/ios/LChat/Views/ThreadListView.swift b/ios/LChat/Views/ThreadListView.swift
index bf54de1..73a6c37 100644
--- a/ios/LChat/Views/ThreadListView.swift
+++ b/ios/LChat/Views/ThreadListView.swift
@@ -76,7 +76,8 @@ struct ThreadListView: View {
},
onClose: { store.setHidden($0, true) },
onPin: { store.setPinned($0, !$0.pinned) },
- onToggleUnread: { $0.unread ? store.markRead($0) : store.markUnread($0) }
+ onToggleUnread: { $0.unread ? store.markRead($0) : store.markUnread($0) },
+ onMute: { store.setMuted($0, !$0.isMuted) }
)
// Extend under the nav bar (and home indicator) so the list scrolls
// *behind* the frosted bar; the collection view's automatic content
diff --git a/ios/LChat/Views/ThreadRow.swift b/ios/LChat/Views/ThreadRow.swift
index f98e233..ae6ce4c 100644
--- a/ios/LChat/Views/ThreadRow.swift
+++ b/ios/LChat/Views/ThreadRow.swift
@@ -38,7 +38,15 @@ struct ThreadRow: View {
.font(.subheadline).foregroundStyle(.secondary)
.lineLimit(2, reservesSpace: true)
Spacer()
- if thread.unread {
+ // A muted chat never escalates: the slashed bell replaces
+ // the unread dot — the state stays visible (never absence
+ // alone), it just doesn't shout. Web-list parity.
+ if thread.isMuted {
+ Image(systemName: "bell.slash.fill")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .accessibilityLabel("Muted")
+ } else if thread.unread {
Circle().fill(Color.accentColor).frame(width: 9, height: 9)
}
}
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index 13100e9..58973c4 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -92,6 +92,25 @@ public final class ChatFeedModel: ObservableObject {
/// NativeFeedModelCache from each ThreadStore fold — the bubble-header
/// lookup (see senderName below).
@Published public var senderNames: [String: String] = [:]
+ /// Who this member has muted (#578) — seeded and refreshed by
+ /// NativeFeedModelCache from the ThreadStore's sealed prefs blob, the same
+ /// route senderNames takes. Render-level only: the fold below is
+ /// untouched, so unmuting reveals history intact.
+ @Published public var mutedPeople: Set<String> = []
+ /// Muted stubs revealed this session, by message id. Deliberately not
+ /// persisted: reopening the app re-folds — a mute means muted (the web's
+ /// revealedMuted set, mirrored).
+ @Published public private(set) var revealedMuted: Set<Int64> = []
+
+ public func revealMuted(_ id: Int64) { revealedMuted.insert(id) }
+
+ /// Whether a message renders as the quiet "Muted message" stub: a muted
+ /// member's, not mine, not yet revealed. uid-less legacy messages never
+ /// fold — there's nothing to key the mute on (web renderItem's exact rule).
+ public func foldsAsMuted(_ m: Message) -> Bool {
+ guard !m.mine, let uid = m.raw.uid, !uid.isEmpty else { return false }
+ return mutedPeople.contains(uid) && !revealedMuted.contains(m.id)
+ }
/// Names currently typing (insertion order), for typingSummary above the
/// composer. Fed by ephemeral chat.typing beats; entries expire on their
/// own clock.
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index 56ccfb6..2dea9ae 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -336,6 +336,13 @@ public struct LChatAPI {
await postJSON("/api/threads/\(thread)/hidden", ["token": token, "hidden": hidden])
}
+ /// Mute/unmute a chat for this user — a per-user marker synced across their
+ /// devices. While muted the SERVER skips every push for the thread; clients
+ /// drop the unread dot and show the slashed bell instead.
+ public func setMuted(thread: String, muted: Bool) async {
+ await postJSON("/api/threads/\(thread)/muted", ["token": token, "muted": muted])
+ }
+
/// Advance this user's read watermark for a thread — per-user, synced across
/// their devices ("read here, read everywhere"). Forward-only server-side, so
/// a stale write from a lagging device never regresses it.
@@ -517,6 +524,40 @@ public struct LChatAPI {
return .failed
}
+ /// The caller's sealed moderation-prefs blob (muted people, #578) and its
+ /// version — the exact contract nicknames() has, including the nil-vs-empty
+ /// distinction (nil = fetch failed, never mistake it for "nothing muted").
+ public func prefs() async -> (blob: String, version: Int64)? {
+ guard var comps = URLComponents(url: baseURL.appendingPathComponent("api/prefs"),
+ resolvingAgainstBaseURL: false) else { return nil }
+ comps.queryItems = [URLQueryItem(name: "token", value: token)]
+ guard let url = comps.url,
+ let (data, resp) = try? await session.data(from: url),
+ (resp as? HTTPURLResponse)?.statusCode == 200 else { return nil }
+ struct Resp: Codable { let prefs_blob: String?; let version: Int64? }
+ guard let r = try? JSONDecoder().decode(Resp.self, from: data) else { return nil }
+ return (r.prefs_blob ?? "", r.version ?? 0)
+ }
+
+ /// Store the sealed prefs blob — the same compare-and-swap contract (and
+ /// result shape) as postNicknames: .conflict means re-read and replay.
+ public func postPrefs(blob: String, version: Int64) async -> NicknamesPost {
+ guard let url = URL(string: "api/prefs", relativeTo: baseURL),
+ let data = try? JSONSerialization.data(withJSONObject:
+ ["token": token, "prefs_blob": blob, "version": version]) 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 }
+ struct Resp: Codable { let version: Int64? }
+ let v = (try? JSONDecoder().decode(Resp.self, from: body))?.version
+ if code == 200 { return .ok(v ?? version + 1) }
+ if code == 409 { return .conflict }
+ return .failed
+ }
+
/// The caller's pinned Away flag (GET /api/me) — what the settings toggle
/// shows. nil when the fetch failed.
public func away() async -> Bool? {
diff --git a/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift b/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift
index fb2847c..01597fd 100644
--- a/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift
+++ b/ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift
@@ -48,6 +48,20 @@ public struct MemberProfileSheet<AvatarView: View>: View {
nickArea
}
}
+
+ // Mute them (#578) — their messages in shared chats fold to a
+ // quiet stub and never notify. Private: they don't find out, and
+ // the server can't read the muted set either. Anyone but yourself
+ // (bots included), matching the web's profile card.
+ if uid != store.myUserId {
+ Button(store.mutedPeople.contains(uid)
+ ? "Unmute \(shownName.isEmpty ? "them" : shownName)"
+ : "Mute \(shownName.isEmpty ? "them" : shownName)") {
+ let on = !store.mutedPeople.contains(uid)
+ Task { await store.setPersonMuted(uid: uid, muted: on) }
+ }
+ .buttonStyle(.borderless)
+ }
}
.padding(24)
.padding(.top, 8) // room for the Done control above the avatar
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
index c20d6b8..5cb8e8b 100644
--- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
+++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
@@ -59,12 +59,20 @@ public struct MessageActions {
/// Messages' "Show Times": toggles the feed's right-hand time gutter.
public var timesShown = false
public var onToggleTimes: (() -> Void)?
+ /// Mute/unmute the sender for me (#578) — wired only on others' messages
+ /// that carry a uid (legacy uid-less messages have nothing to key on).
+ /// The label needs the resolved display name, so it rides here too.
+ public var senderMuted = false
+ public var senderLabel = ""
+ public var onMutePerson: (() -> Void)?
public init(canEdit: Bool = false,
onReply: (() -> Void)? = nil, onEdit: (() -> Void)? = nil,
onDelete: (() -> Void)? = nil, onCopy: (() -> Void)? = nil,
onReact: ((String) -> Void)? = nil,
- timesShown: Bool = false, onToggleTimes: (() -> Void)? = nil) {
+ timesShown: Bool = false, onToggleTimes: (() -> Void)? = nil,
+ senderMuted: Bool = false, senderLabel: String = "",
+ onMutePerson: (() -> Void)? = nil) {
self.canEdit = canEdit
self.onReply = onReply
self.onEdit = onEdit
@@ -73,6 +81,16 @@ public struct MessageActions {
self.onReact = onReact
self.timesShown = timesShown
self.onToggleTimes = onToggleTimes
+ self.senderMuted = senderMuted
+ self.senderLabel = senderLabel
+ self.onMutePerson = onMutePerson
+ }
+
+ /// "Mute Alice" / "Unmute Alice" — one builder so the SwiftUI menu and the
+ /// Mac NSMenu can't drift ("them" when no name resolved, like the web).
+ public var mutePersonTitle: String {
+ let who = senderLabel.isEmpty ? "them" : senderLabel
+ return senderMuted ? "Unmute \(who)" : "Mute \(who)"
}
}
@@ -269,6 +287,9 @@ public struct MessageBubble: View {
if actions.canEdit, let edit = actions.onEdit {
Button("Edit…", action: edit)
}
+ if let mute = actions.onMutePerson {
+ Button(actions.mutePersonTitle, action: mute)
+ }
if message.mine, let delete = actions.onDelete {
Divider() // the native idiom for a destructive action set apart
Button("Delete…", role: .destructive, action: delete)
@@ -944,3 +965,40 @@ public struct MessageBubble: View {
)
}
}
+
+// The quiet stand-in for a muted member's message (#578) — visible and
+// tappable rather than silently gone, because the honest framing of a mute is
+// "you stop seeing them", not "they stop existing". Tapping reveals that one
+// message for the session (the container tracks reveals; a mute means muted
+// across launches). Shared by the iOS and Mac feeds and their thread views —
+// the web's .muted-stub, mirrored. Always a "them" row: own messages never fold.
+public struct MutedMessageStub: View {
+ let onShow: () -> Void
+
+ public init(onShow: @escaping () -> Void) {
+ self.onShow = onShow
+ }
+
+ public var body: some View {
+ Button(action: onShow) {
+ HStack(spacing: 8) {
+ Text("Muted message")
+ .italic()
+ .foregroundStyle(.secondary)
+ Text("Show")
+ .fontWeight(.semibold)
+ .foregroundStyle(.tint)
+ }
+ // Relative sizes so the pill tracks Dynamic Type / the Mac's
+ // Text Size pref like every other feed line.
+ .font(.footnote)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(Capsule().strokeBorder(.quaternary, lineWidth: 1))
+ .contentShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ // One element, one act: the whole pill is the button, spoken as such.
+ .accessibilityLabel("Muted message, show")
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift b/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift
index bd890c9..9da27d4 100644
--- a/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift
+++ b/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift
@@ -51,6 +51,9 @@ struct MessageRightClick: NSViewRepresentable {
if actions.canEdit, let edit = actions.onEdit {
menu.addItem(ClosureMenuItem(title: "Edit…", handler: edit))
}
+ if let mute = actions.onMutePerson {
+ menu.addItem(ClosureMenuItem(title: actions.mutePersonTitle, handler: mute))
+ }
if mine, let delete = actions.onDelete {
menu.addItem(.separator())
let item = ClosureMenuItem(title: "Delete…", handler: delete)
diff --git a/ios/LChatCore/Sources/LChatCore/ModPrefs.swift b/ios/LChatCore/Sources/LChatCore/ModPrefs.swift
new file mode 100644
index 0000000..1e6d1e7
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/ModPrefs.swift
@@ -0,0 +1,32 @@
+import Foundation
+
+// Private moderation preferences (#578): who *I've* muted, synced across my
+// devices as one blob sealed to my OWN public key — the exact posture the
+// nicknames blob uses (Nicknames.swift). Nothing changes for a muted person
+// and the server can't read the set; only this member's devices can.
+//
+// The JSON shape is the cross-platform contract — { v: 1, mutedPeople:
+// ["uid", …] }, exactly what the web seals (shell.js saveModPrefsNow). Match
+// it byte-for-name; drift here would silently fork the muted set per device.
+public enum ModPrefs {
+ private struct Blob: Codable {
+ var v: Int
+ // Optional on decode so a future blob that dropped/renamed the field
+ // reads as "nobody muted" instead of failing the whole open.
+ var mutedPeople: [String]?
+ }
+
+ /// Opens a sealed prefs blob with the member's PKCS#8 private key.
+ public static func open(blobB64: String, privPkcs8B64: String) throws -> Set<String> {
+ let plain = try ECIES.open(sealedB64: blobB64, privPkcs8B64: privPkcs8B64)
+ return Set((try JSONDecoder().decode(Blob.self, from: plain)).mutedPeople ?? [])
+ }
+
+ /// Seals a muted-people set to the member's own public key. Sorted so the
+ /// same set always seals the same plaintext (the ciphertext still varies —
+ /// fresh ephemeral key — but the contract stays deterministic).
+ public static func seal(_ muted: Set<String>, recipientPubB64: String) throws -> String {
+ let plain = try JSONEncoder().encode(Blob(v: 1, mutedPeople: muted.sorted()))
+ return try ECIES.seal(bytes: plain, recipientPubB64: recipientPubB64)
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift
index 4d14b29..2ed7b4f 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 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
public var open_key: String? // the thread key, encrypted under the instance key
@@ -363,6 +364,10 @@ 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
+ // 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.
+ public var muted: Bool?
public var members: [String]
public var threadKeyB64: String // unwrapped — handed to the webview to render the feed
public var avatar: AvatarSource? // the person's / group's photo, if one is set + we hold the key
@@ -373,6 +378,8 @@ public struct ChatThread: Identifiable, Equatable, Codable {
// The row's presence dot ("online" | "away"; nil = none) — set for DM/self
// rows only, from the other member's (or your own) roster presence.
public var presence: String? = nil
+
+ public var isMuted: Bool { muted ?? false }
}
// A one-line snippet for the sidebar: "Name: text" in groups, just text in DMs —
diff --git a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift
index 57b9ee3..b43b76c 100644
--- a/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift
+++ b/ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift
@@ -77,6 +77,19 @@ public final class NativeFeedModelCache {
onNamesUpdate?(instanceID, labels)
}
+ // The muted-people set per instance (#578) — the senderNames treatment:
+ // seeded into models handed out later, pushed into live/parked ones now,
+ // so a mute folds the person's bubbles in every open feed at once.
+ private var mutedPeople: [String: Set<String>] = [:]
+
+ /// A fresh prefs load's (or local toggle's) muted set — from ThreadStore.
+ public func updateMutedPeople(instance instanceID: String, muted: Set<String>) {
+ mutedPeople[instanceID] = muted
+ for (key, entry) in entries where key.hasPrefix(instanceID + "|") {
+ if entry.model.mutedPeople != muted { entry.model.mutedPeople = muted }
+ }
+ }
+
/// The live model for a thread — reused (and re-marked recent) when parked,
/// created and started when not.
public func model(for instance: SavedInstance, thread: ChatThread, myUserId: String) -> ChatFeedModel {
@@ -95,6 +108,7 @@ public final class NativeFeedModelCache {
entries.removeValue(forKey: key)?.model.stop()
let model = ChatFeedModel(instance: instance, thread: thread, myUserId: myUserId)
model.senderNames = senderNames[instance.id] ?? [:]
+ model.mutedPeople = mutedPeople[instance.id] ?? []
model.integrity = integrityMonitor(for: instance) // tamper-evidence gossip
model.start()
entries[key] = Entry(model: model, seedKey: thread.threadKeyB64)
diff --git a/ios/LChatCore/Sources/LChatCore/SharedThreadKeys.swift b/ios/LChatCore/Sources/LChatCore/SharedThreadKeys.swift
index 7b575a2..be90eee 100644
--- a/ios/LChatCore/Sources/LChatCore/SharedThreadKeys.swift
+++ b/ios/LChatCore/Sources/LChatCore/SharedThreadKeys.swift
@@ -28,6 +28,12 @@ public enum SharedThreadKeys {
// instead of the payload's snapshot. Optional: older entries decode
// and fall back to the snapshot.
public var names: [String: String]?
+ // This user muted the chat (#578): the NSE delivers its pushes as
+ // passive, silent entries. Belt-and-braces — the server already skips
+ // pushes for muted threads; this covers entries parked before an
+ // unmute raced a stale park, and older servers. Optional so entries
+ // parked by older builds decode as unmuted.
+ public var muted: Bool?
}
private static let service = "com.lchat.client.threadkeys"
@@ -98,3 +104,74 @@ public enum SharedThreadKeys {
return nil
}
}
+
+// The muted-people set (#578), parked for the notification extension the way
+// SharedThreadKeys parks thread keys: the NSE decides AFTER decrypting whether
+// the sender is muted (the uid rides inside the ciphertext, so the server —
+// which never learns who's muted — can't skip these pushes for us), and goes
+// passive when they are. One entry per instance origin; plaintext uids are
+// fine here — the shared keychain is the same trust domain as the parked
+// thread keys beside them. Refreshed whenever the prefs blob loads or saves.
+public enum SharedMutedPeople {
+ private static let service = "com.lchat.client.mutedpeople"
+ private static let accessGroup = "J5NDF5PCD8.com.elevenmessenger.messenger.shared"
+
+ // Same skip-unchanged + off-main write posture as SharedThreadKeys —
+ // saves fire on every prefs load and a Keychain write costs main-thread ms.
+ private static var savedFingerprints: [String: Int] = [:]
+ private static let fingerprintLock = NSLock()
+ private static let writeQueue = DispatchQueue(label: "shared-muted-people", qos: .utility)
+
+ public static func save(origin: String, uids: Set<String>) {
+ guard let data = try? JSONEncoder().encode(uids.sorted()) else { return }
+ let fingerprint = data.hashValue
+ fingerprintLock.lock()
+ let unchanged = savedFingerprints[origin] == fingerprint
+ if !unchanged { savedFingerprints[origin] = fingerprint }
+ fingerprintLock.unlock()
+ if unchanged { return }
+ writeQueue.async { write(origin: origin, data: data) }
+ }
+
+ private static func write(origin: String, data: Data) {
+ var base: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: origin,
+ kSecUseDataProtectionKeychain as String: true,
+ kSecAttrAccessGroup as String: accessGroup,
+ ]
+ var add = base
+ add[kSecValueData as String] = data
+ add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
+ SecItemDelete(base as CFDictionary)
+ if SecItemAdd(add as CFDictionary, nil) == errSecSuccess { return }
+ // No sharing entitlement (dev build): keep it app-local; the NSE then
+ // simply sees nobody muted — the banner degrades, never errors.
+ base.removeValue(forKey: kSecAttrAccessGroup as String)
+ add.removeValue(forKey: kSecAttrAccessGroup as String)
+ SecItemDelete(base as CFDictionary)
+ SecItemAdd(add as CFDictionary, nil)
+ }
+
+ public static func lookup(origin: String) -> Set<String> {
+ for withGroup in [true, false] {
+ var query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: origin,
+ kSecUseDataProtectionKeychain as String: true,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+ if withGroup { query[kSecAttrAccessGroup as String] = accessGroup }
+ var item: CFTypeRef?
+ if SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
+ let data = item as? Data,
+ let uids = try? JSONDecoder().decode([String].self, from: data) {
+ return Set(uids)
+ }
+ }
+ return []
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index 092aabe..9e45c95 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -42,6 +42,16 @@ public final class ThreadStore: ObservableObject {
// these on top — the web client's journal (shell.js), mirrored.
private var nicknameOps: [String: NicknameEntry?] = [:]
+ // Muted people (#578): who I've muted, opened from the sealed prefs blob
+ // (/api/prefs) — the same seal-to-my-own-key + CAS shape as nicknames.
+ // Their messages fold to a quiet stub and never notify; nothing changes
+ // for them, and the server never learns who's muted.
+ @Published public private(set) var mutedPeople: Set<String> = []
+ private var prefsVersion: Int64 = 0
+ // uid → true (mute) / false (unmute), pending sync — the nicknames journal
+ // shape, replayed over a fresh server read on every save.
+ private var mutedOps: [String: Bool] = [:]
+
private var api: LChatAPI? {
guard let base = instance.baseURL else { return nil }
return LChatAPI(baseURL: base, token: instance.deviceToken)
@@ -105,6 +115,7 @@ public final class ThreadStore: ObservableObject {
// Independent of the fetches below — runs concurrently, off the hot
// path (refresh refires on every socket frame).
async let nickFetchAsync = api.nicknames()
+ async let prefsFetchAsync = api.prefs()
async let gatesAsync = api.featureGates()
let people = await api.roster()
@@ -135,6 +146,7 @@ public final class ThreadStore: ObservableObject {
let tierRows = await api.tiers()
// nil = fetch failed (keep what we hold); "" = genuinely never set.
let nickFetch = await nickFetchAsync
+ let prefsFetch = await prefsFetchAsync
let gates = await gatesAsync
botsEnabled = gates.botsEnabled
appsEnabled = gates.appsEnabled
@@ -152,11 +164,19 @@ public final class ThreadStore: ObservableObject {
? nickFetch?.blob : nil
let heldNicknames = nicknames
let pendingOps = nicknameOps
+ // The prefs blob (muted people) rides the same rules: adopt only a
+ // copy at least as new as ours; nil (failed fetch) keeps what we hold.
+ let prefsBlob = (prefsFetch != nil && prefsFetch!.version >= prefsVersion)
+ ? prefsFetch?.blob : nil
+ let heldMuted = mutedPeople
+ let pendingMutedOps = mutedOps
let folded = await Task.detached(priority: .userInitiated) {
Self.fold(instance: inst, myUserId: uid, peopleIn: people,
wrappedInstanceKey: wrappedInstanceKey, serverThreads: serverThreads,
nicknamesBlob: nickBlob, heldNicknames: heldNicknames,
- pendingOps: pendingOps, tierRows: tierRows)
+ pendingOps: pendingOps, prefsBlob: prefsBlob,
+ heldMuted: heldMuted, pendingMutedOps: pendingMutedOps,
+ tierRows: tierRows)
}.value
instanceKeyB64 = folded.instanceKeyB64
tierKeysB64 = folded.tierKeysB64
@@ -173,9 +193,13 @@ public final class ThreadStore: ObservableObject {
nicknames = folded.nicknames
if let nf = nickFetch, nf.version >= nicknamesVersion { nicknamesVersion = nf.version }
displayLabels = folded.displayLabels
+ mutedPeople = folded.mutedPeople
+ if let pf = prefsFetch, pf.version >= prefsVersion { prefsVersion = pf.version }
+ pushMutedPeople()
// A save that failed earlier left ops queued — retry off this refresh
// (the web twin's queueNickRetry; without it a rename could be lost).
if !nicknameOps.isEmpty { queueNickRetry(delay: 0) }
+ if !mutedOps.isEmpty { queuePrefsRetry(delay: 0) }
// Hand the fresh names to the native feed models (live and parked) —
// bubble headers resolve senders by uid, my name for them winning.
NativeFeedModelCache.shared.updateSenderNames(instance: instance.id,
@@ -221,6 +245,9 @@ public final class ThreadStore: ObservableObject {
// in (what titles, sender headers, and member lists display).
var nicknames: [String: NicknameEntry]
var displayLabels: [String: String]
+ // Muted people (#578), opened from the sealed prefs blob with the
+ // pending ops replayed — the nicknames treatment, mirrored.
+ var mutedPeople: Set<String>
var rows: [ChatThread]
// Raw server read watermarks by thread — merged into serverReadMarks
// on the main actor (the fold runs detached and can't touch it).
@@ -234,6 +261,9 @@ public final class ThreadStore: ObservableObject {
nicknamesBlob: String?,
heldNicknames: [String: NicknameEntry],
pendingOps: [String: NicknameEntry?],
+ prefsBlob: String? = nil,
+ heldMuted: Set<String> = [],
+ pendingMutedOps: [String: Bool] = [:],
tierRows: [TierRow] = []) -> Folded {
var people = peopleIn
var instanceKeyB64: String?
@@ -289,6 +319,20 @@ public final class ThreadStore: ObservableObject {
let displayLabels = rosterLabels.reduce(into: [String: String]()) { out, kv in
out[kv.key] = Nicknames.displayName(nicknames[kv.key], label: kv.value)
}
+ // Muted people (#578): the same adopt-or-hold + replay dance as the
+ // nicknames above — a blip never reads as "nobody muted", and a mute
+ // whose save hasn't landed yet must not visibly unmute on refresh.
+ var mutedPeople = heldMuted
+ if let blob = prefsBlob {
+ if blob.isEmpty {
+ mutedPeople = []
+ } else if let opened = try? ModPrefs.open(blobB64: blob, privPkcs8B64: instance.userPriv) {
+ mutedPeople = opened
+ }
+ }
+ for (uid, on) in pendingMutedOps {
+ if on { mutedPeople.insert(uid) } else { mutedPeople.remove(uid) }
+ }
// Member profile-photo blob ids, for DM/self avatars (decrypted under the
// instance key, like the labels above). Groups use their own thread key.
let rosterAvatars = Dictionary(
@@ -447,7 +491,8 @@ public final class ThreadStore: ObservableObject {
})
SharedThreadKeys.save(threadID: t.id, entry: .init(
key: raw, title: title, origin: instance.origin, group: t.kind == "group",
- myUid: myUserId, names: memberNames.isEmpty ? nil : memberNames))
+ myUid: myUserId, names: memberNames.isEmpty ? nil : memberNames,
+ muted: t.muted ?? false))
rows.append(ChatThread(
id: t.id,
kind: t.kind,
@@ -459,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,
+ muted: t.muted,
members: members,
threadKeyB64: raw.base64urlEncodedString(),
avatar: avatar,
@@ -482,7 +528,8 @@ public final class ThreadStore: ObservableObject {
rows.sort(by: order)
return Folded(instanceKeyB64: instanceKeyB64, tierKeysB64: tierKeysB64, people: people,
rosterLabels: rosterLabels, nicknames: nicknames,
- displayLabels: displayLabels, rows: rows, readMarks: readMarks)
+ displayLabels: displayLabels, mutedPeople: mutedPeople,
+ rows: rows, readMarks: readMarks)
}
// MARK: - Per-viewer names (#577)
@@ -633,6 +680,116 @@ public final class ThreadStore: ObservableObject {
}
}
+ // MARK: - Muted people (#578)
+
+ /// Mute or unmute a person, applying locally at once (the sync is
+ /// eventual, like the web's setPersonMuted). Their messages in shared
+ /// chats fold to a quiet stub and their pushes go passive; they never
+ /// find out, and the server can't read the muted set either. Returns
+ /// whether the save landed (false = applied locally, op queued, retried —
+ /// never lost silently).
+ @discardableResult
+ public func setPersonMuted(uid: String, muted on: Bool) async -> Bool {
+ guard !uid.isEmpty, uid != myUserId else { return false }
+ if on { mutedPeople.insert(uid) } else { mutedPeople.remove(uid) }
+ mutedOps[uid] = on
+ pushMutedPeople()
+ return await savePrefs()
+ }
+
+ // The live muted set, fanned out to every consumer that can't observe
+ // this store: the parked/live feed models (their stubs re-fold off the
+ // published set) and the NSE's shared park. Called whenever the set
+ // changes — a local toggle, a save's merge, or a refresh's server copy.
+ private func pushMutedPeople() {
+ NativeFeedModelCache.shared.updateMutedPeople(instance: instance.id, muted: mutedPeople)
+ let origin = instance.origin
+ let set = mutedPeople
+ Task.detached(priority: .utility) {
+ SharedMutedPeople.save(origin: origin, uids: set)
+ }
+ }
+
+ // Saves are serialized like the nicknames chain — two quick mutes must
+ // not interleave their read/POST pairs.
+ private var prefsSaveChain: Task<Bool, Never>?
+
+ private func savePrefs() async -> Bool {
+ let prev = prefsSaveChain
+ let task = Task { [weak self] () -> Bool in
+ _ = await prev?.value
+ guard let self else { return false }
+ return await self.savePrefsNow()
+ }
+ prefsSaveChain = task
+ return await task.value
+ }
+
+ /// Read-modify-write with compare-and-swap — saveNicknamesNow's exact
+ /// shape over the prefs blob: re-read, replay this device's pending ops,
+ /// seal to my own key, POST with the version read. A conflict re-reads
+ /// and retries; a failure leaves the ops queued for queuePrefsRetry.
+ /// Mirrors saveModPrefsNow in web/static/shell.js.
+ private func savePrefsNow() async -> Bool {
+ guard let api, !mutedOps.isEmpty else { return mutedOps.isEmpty }
+ let priv = instance.userPriv
+ let pub = instance.userPub
+ for _ in 0..<3 {
+ guard let (blob, version) = await api.prefs() else { break }
+ let included = mutedOps
+ // ECIES open + seal off the main actor, like the nicknames save.
+ let built = await Task.detached(priority: .userInitiated) { () -> (sealed: String, merged: Set<String>)? in
+ var merged: Set<String> = []
+ if !blob.isEmpty {
+ guard let opened = try? ModPrefs.open(blobB64: blob, privPkcs8B64: priv) else {
+ return nil // never overwrite a blob we can't read
+ }
+ merged = opened
+ }
+ for (uid, on) in included {
+ if on { merged.insert(uid) } else { merged.remove(uid) }
+ }
+ guard let sealed = try? ModPrefs.seal(merged, recipientPubB64: pub) else { return nil }
+ return (sealed, merged)
+ }.value
+ guard let built else { break }
+ switch await api.postPrefs(blob: built.sealed, version: version) {
+ case .ok(let v):
+ prefsVersion = v
+ mutedPeople = built.merged // adopt another device's writes too
+ // Clear only what this save wrote — an op re-set mid-save stays.
+ for (uid, on) in included where mutedOps[uid] == on {
+ mutedOps.removeValue(forKey: uid)
+ }
+ pushMutedPeople()
+ return true
+ case .conflict:
+ continue // fresh read next loop; the replay converges
+ case .failed:
+ break
+ }
+ break
+ }
+ queuePrefsRetry()
+ return false
+ }
+
+ // The nicknames retry, mirrored: a failed prefs save keeps trying while
+ // ops are queued (and every refresh re-drives it). One retry at a time.
+ private var prefsRetryScheduled = false
+ private func queuePrefsRetry(delay: UInt64 = 5) {
+ guard !prefsRetryScheduled, !mutedOps.isEmpty else { return }
+ prefsRetryScheduled = true
+ Task { [weak self] in
+ try? await Task.sleep(nanoseconds: delay * 1_000_000_000)
+ guard let self else { return }
+ self.prefsRetryScheduled = false
+ if !self.mutedOps.isEmpty {
+ _ = await self.savePrefs()
+ }
+ }
+ }
+
// Pinned chats first, then most-recent activity — mirrors the web list.
nonisolated public static func order(_ a: ChatThread, _ b: ChatThread) -> Bool {
(a.pinned != b.pinned) ? a.pinned : a.lastAt > b.lastAt
@@ -711,6 +868,22 @@ public final class ThreadStore: ObservableObject {
Task { await api?.setPinned(thread: thread.id, pinned: pinned) }
}
+ /// Mute or unmute a chat: no unread dot, no notifications — the server
+ /// skips its pushes while muted, and the flag syncs to the user's other
+ /// devices. Optimistic local flip (ordering deliberately unchanged — a
+ /// muted chat keeps its place, it just stays quiet), then POST; the
+ /// follow-up refresh re-parks the SharedThreadKeys entry so the NSE's
+ /// belt-and-braces gate sees the new state without waiting for activity.
+ public func setMuted(_ thread: ChatThread, _ muted: Bool) {
+ guard let i = threads.firstIndex(where: { $0.id == thread.id }) else { return }
+ threads[i].muted = muted
+ let id = thread.id
+ Task {
+ await api?.setMuted(thread: id, muted: muted)
+ await refresh()
+ }
+ }
+
/// Close (hidden=true) or reopen (hidden=false) a chat. ThreadListView hides
/// closed chats from the default list; the row stays in the store so search can
/// still surface it. Syncs to the user's other devices via the server flag.
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 9ae64b5..213b3fb 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -516,6 +516,12 @@ struct NativeChatView: View {
store.flatMap { s in
s.roster.first { $0.user_id == uid }.flatMap(s.avatarSource(for:))
}
+ },
+ // Mute/unmute a sender (#578) — the feed reads
+ // the muted set off its model; writes go through
+ // the store's sealed-prefs save.
+ onMutePerson: { [weak store] uid, on in
+ Task { await store?.setPersonMuted(uid: uid, muted: on) }
})
.id(thread.id + "|" + thread.threadKeyB64 + "|"
+ String(UInt(bitPattern: ObjectIdentifier(model).hashValue)))
@@ -610,6 +616,7 @@ struct NativeChatView: View {
Divider()
}
Button(t.pinned ? "Unpin" : "Pin") { store.setPinned(t, !t.pinned) }
+ Button(t.isMuted ? "Unmute chat" : "Mute chat") { store.setMuted(t, !t.isMuted) }
Button(t.unread ? "Mark as Read" : "Mark as Unread") {
t.unread ? store.markRead(t) : store.markUnread(t)
}
@@ -1242,7 +1249,14 @@ struct NativeChatView: View {
}
}
// Outside the text column so the dot centres on the ROW.
- if t.unread {
+ // A muted chat never escalates: the slashed bell replaces the
+ // unread dot — state stays visible, it just doesn't shout.
+ if t.isMuted {
+ Image(systemName: "bell.slash.fill")
+ .font(.system(size: 10))
+ .foregroundStyle(.secondary)
+ .accessibilityLabel("Muted")
+ } else if t.unread {
Circle().fill(Color.accentColor).frame(width: 8, height: 8)
}
}
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index d5ab6c2..2682fff 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -104,7 +104,8 @@ struct NativeFeedView: View {
init(model: ChatFeedModel, thread: ChatThread, myUserId: String, isGroup: Bool,
isActive: Bool = true, roster: [RosterEntry],
instance: SavedInstance? = nil, instanceKeyB64: String? = nil,
- mentionAvatar: @escaping (String) -> AvatarSource?) {
+ mentionAvatar: @escaping (String) -> AvatarSource?,
+ 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.
_model = StateObject(wrappedValue: model)
@@ -135,6 +136,7 @@ struct NativeFeedView: View {
self.thread = thread
self.instance = instance
self.instanceKeyB64 = instanceKeyB64
+ self.onMutePerson = onMutePerson
}
private let defaultCommand: AppDefaultCommand.Spec?
@@ -144,6 +146,9 @@ struct NativeFeedView: View {
private let thread: ChatThread
private let instance: SavedInstance?
private let instanceKeyB64: String?
+ // 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)?
// The companion app's start-of-history intro (chatIntro on the web),
// shown above the earliest message once all history is loaded.
@@ -667,11 +672,47 @@ struct NativeFeedView: View {
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(EdgeInsets(top: 3, leading: 14, bottom: 3, trailing: 14))
+ } else if model.foldsAsMuted(m) {
+ mutedRow(m, previous: previous)
} else {
bubbleRow(m, previous: previous, proxy: proxy)
}
}
+ // A muted member's message (#578): the quiet stub, revealed per-message
+ // for the session. Keeps the run/separator grouping so revealing it never
+ // reshuffles the feed around it. Always a "them" row — own messages and
+ // uid-less legacy ones never fold (foldsAsMuted).
+ @ViewBuilder
+ private func mutedRow(_ m: ChatFeedModel.Message, previous: ChatFeedModel.Message?) -> some View {
+ let separator = timeSeparator(at: m.at, previous: previous?.at)
+ let startsRun = separator != nil || !ChatFeedModel.sameSender(previous, m)
+ if let separator {
+ separatorRow(separator, at: m.at)
+ }
+ HStack(spacing: 0) {
+ MutedMessageStub { model.revealMuted(m.id) }
+ Spacer(minLength: 48)
+ }
+ .padding(EdgeInsets(top: startsRun ? 8 : 2, leading: 14,
+ bottom: 2, trailing: 10))
+ }
+
+ // The dated/time-only run separator — one builder for bubble and muted rows.
+ private func separatorRow(_ separator: TimeSeparator, at: Int64) -> some View {
+ HStack(spacing: 4) {
+ if separator == .dated {
+ Text(chatDayLabel(at)).fontWeight(.semibold)
+ }
+ Text(chatTime(at))
+ }
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity)
+ .padding(.top, 14)
+ .padding(.bottom, 4)
+ }
+
@ViewBuilder
private func bubbleRow(_ m: ChatFeedModel.Message, previous: ChatFeedModel.Message?,
proxy: ScrollViewProxy) -> some View {
@@ -681,17 +722,7 @@ struct NativeFeedView: View {
let separator = timeSeparator(at: m.at, previous: previous?.at)
let startsRun = separator != nil || !ChatFeedModel.sameSender(previous, m)
if let separator {
- HStack(spacing: 4) {
- if separator == .dated {
- Text(chatDayLabel(m.at)).fontWeight(.semibold)
- }
- Text(chatTime(m.at))
- }
- .font(.system(size: 12))
- .foregroundStyle(.secondary)
- .frame(maxWidth: .infinity)
- .padding(.top, 14)
- .padding(.bottom, 4)
+ separatorRow(separator, at: m.at)
}
HStack(spacing: 0) {
if m.mine { Spacer(minLength: 48) }
@@ -709,22 +740,7 @@ struct NativeFeedView: View {
onFileTap: { att in download(att) },
onVideoTap: { att, frame in playVideo(att, from: frame) },
downloading: videoDownload,
- actions: MessageActions(
- canEdit: model.canEdit(m),
- onReply: { startReply(m) },
- onEdit: { startEdit(m) },
- onDelete: { model.deleteMessage(m.id) },
- onCopy: {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(m.text, forType: .string)
- },
- onReact: { emoji in model.toggleReaction(m.id, emoji: emoji) },
- timesShown: timesShown,
- onToggleTimes: {
- withAnimation(.easeOut(duration: 0.25)) {
- timesReveal = timesShown ? 0 : Self.timeGutter
- }
- }),
+ actions: messageActions(m, times: true),
dragItem: { att in
guard att.isImage || att.isVideo else { return nil }
return AttachmentDragItem(filename: att.name ?? (att.isImage ? "photo.jpg" : "video.mp4"),
@@ -1335,7 +1351,48 @@ struct NativeFeedView: View {
}
}
+ // One catalog for the feed and the thread view — reply/edit/delete/copy/
+ // react (the feed adds Show Times), plus Mute/Unmute-sender (#578) on
+ // others' uid-carrying messages (uid-less legacy ones can't be keyed).
+ private func messageActions(_ m: ChatFeedModel.Message, times: Bool) -> MessageActions {
+ var actions = MessageActions(
+ canEdit: model.canEdit(m),
+ onReply: { startReply(m) },
+ onEdit: { startEdit(m) },
+ onDelete: { model.deleteMessage(m.id) },
+ onCopy: {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(m.text, forType: .string)
+ },
+ onReact: { emoji in model.toggleReaction(m.id, emoji: emoji) })
+ if times {
+ actions.timesShown = timesShown
+ actions.onToggleTimes = {
+ withAnimation(.easeOut(duration: 0.25)) {
+ timesReveal = timesShown ? 0 : Self.timeGutter
+ }
+ }
+ }
+ if let onMutePerson, !m.mine, let uid = m.raw.uid, !uid.isEmpty {
+ let muted = model.mutedPeople.contains(uid)
+ actions.senderMuted = muted
+ actions.senderLabel = model.senderName(m)
+ actions.onMutePerson = { onMutePerson(uid, !muted) }
+ }
+ return actions
+ }
+
+ @ViewBuilder
private func threadBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> some View {
+ // The muted fold holds inside the thread view too (#578).
+ if model.foldsAsMuted(m) {
+ MutedMessageStub { model.revealMuted(m.id) }
+ } else {
+ threadMessageBubble(m, showSender: showSender)
+ }
+ }
+
+ private func threadMessageBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> some View {
MessageBubble(message: m, showSender: showSender,
senderName: model.senderName(m),
theme: theme,
@@ -1348,16 +1405,7 @@ struct NativeFeedView: View {
onFileTap: { att in download(att) },
onVideoTap: { att, frame in playVideo(att, from: frame) },
downloading: videoDownload,
- actions: MessageActions(
- canEdit: model.canEdit(m),
- onReply: { startReply(m) },
- onEdit: { startEdit(m) },
- onDelete: { model.deleteMessage(m.id) },
- onCopy: {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(m.text, forType: .string)
- },
- onReact: { emoji in model.toggleReaction(m.id, emoji: emoji) }),
+ actions: messageActions(m, times: false),
dragItem: { att in
guard att.isImage || att.isVideo else { return nil }
return AttachmentDragItem(filename: att.name ?? (att.isImage ? "photo.jpg" : "video.mp4"),
diff --git a/ios/LChatNSE/NotificationService.swift b/ios/LChatNSE/NotificationService.swift
index a92f1bd..2a20c3a 100644
--- a/ios/LChatNSE/NotificationService.swift
+++ b/ios/LChatNSE/NotificationService.swift
@@ -47,6 +47,17 @@ final class NotificationService: UNNotificationServiceExtension {
return
}
+ // Muted (#578): a muted THREAD (flag parked with the key — belt and
+ // braces, the server already skips these pushes) or a muted PERSON
+ // (the shared muted-people set; their uid rides inside the ciphertext,
+ // so only this post-decrypt point can know) never buzzes. Without the
+ // filtering entitlement the extension must ship *something*, so muted
+ // pushes take the same passive, silent shape the non-author reaction
+ // path uses — no sound, no banner, no lit screen.
+ let mutedPeople = SharedMutedPeople.lookup(origin: entry.origin)
+ let quiet = (entry.muted ?? false)
+ || (payload.uid.map { mutedPeople.contains($0) } ?? false)
+
// Reaction events notify only the reacted-to message's author. The
// author's uid (targetUid) rides inside the ciphertext — stamped by the
// reacting client, invisible to the server — so the decision can only
@@ -70,7 +81,7 @@ final class NotificationService: UNNotificationServiceExtension {
content.title = entry.title
content.threadIdentifier = thread
let toMine = payload.on == true && payload.targetUid != nil && payload.targetUid == entry.myUid
- if toMine {
+ if toMine && !quiet {
// In a DM the title already names the reactor; groups prefix them.
content.body = entry.group
? "\(sender) reacted \(emoji) to your message"
@@ -96,15 +107,23 @@ final class NotificationService: UNNotificationServiceExtension {
// In a DM the title already names the sender; groups prefix them.
content.body = entry.group ? "\(sender): \(text)" : text
content.threadIdentifier = thread
+ if quiet {
+ // Muted thread or muted sender: full content (it's the member's
+ // own device — nothing to hide), but silent and passive; it only
+ // lands in the notification list.
+ content.sound = nil
+ content.interruptionLevel = .passive
+ }
// Restyle as a communication notification — the sender's photo (parked
// in SharedAvatarCache by the app) as the banner icon, the app icon
// badged on its corner. Only a real person-to-person message (named
// sender) qualifies; app/system events keep the plain banner, as does
// anything the restyle can't handle (no entitlement, no cached photo →
- // monogram is still applied by the system).
+ // monogram is still applied by the system). Muted pushes skip the
+ // dress-up — a passive list entry doesn't want a contact banner.
var deliver: UNNotificationContent = content
- if let name = senderName(payload.uid, payload.name), !name.isEmpty,
+ if !quiet, let name = senderName(payload.uid, payload.name), !name.isEmpty,
let styled = CommunicationNotification.decorate(
content, senderName: name, senderUID: payload.uid,
origin: entry.origin, conversationID: thread,
diff --git a/main.go b/main.go
index d940edb..e4853e1 100644
--- a/main.go
+++ b/main.go
@@ -442,6 +442,8 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("POST /api/description", s.postDescription)
mux.HandleFunc("GET /api/nicknames", s.getNicknames)
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/group-description", s.postGroupDescription)
mux.HandleFunc("GET /api/threads", s.getThreads)
mux.HandleFunc("POST /api/threads", s.postThread)
@@ -457,6 +459,7 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("POST /api/threads/{id}/remove", s.postThreadRemove)
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}/read", s.postThreadRead)
// Mini-apps (miniapps.go): scheduled "future messages" + per-thread state
@@ -2146,6 +2149,68 @@ func (s *Server) postNicknames(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
}
+// The caller's private moderation preferences (muted people, for now): one
+// blob sealed by the client to the caller's OWN public key, exactly like the
+// nicknames blob. Who you mute is nobody else's business — not the muted
+// person's, not the server's; only the owner's devices can open it.
+func (s *Server) getPrefs(w http.ResponseWriter, r *http.Request) {
+ token := r.URL.Query().Get("token")
+ if !s.tokenOK(token) {
+ httpError(w, http.StatusForbidden, "not a member")
+ return
+ }
+ uid, ok := s.db.deviceTokenUser(token)
+ if !ok {
+ httpError(w, http.StatusForbidden, "not a member")
+ return
+ }
+ blob, version, err := s.db.userPrefsBlob(uid)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"prefs_blob": blob, "version": version})
+}
+
+func (s *Server) postPrefs(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ PrefsBlob string `json:"prefs_blob"`
+ // The version the writer last read (compare-and-swap); absent skips
+ // the check, like the nicknames blob.
+ Version *int64 `json:"version"`
+ }
+ 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)
+ // A muted-people set is one sealed entry per person (~100 bytes each);
+ // 64 KiB is the same generous ceiling the nicknames blob uses.
+ if !ok || len(body.PrefsBlob) > 64*1024 {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ base := int64(-1)
+ if body.Version != nil && *body.Version >= 0 {
+ base = *body.Version
+ }
+ version, wrote, err := s.db.setUserPrefsBlob(uid, body.PrefsBlob, base)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ if !wrote {
+ writeJSON(w, http.StatusConflict, map[string]any{"error": "version conflict", "version": version})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
+}
+
// postGroupDescription sets the space's description (admin only — the space's
// self-portrait is a branding decision, like its name). Plaintext, like the
// admin name/email: it must be editable from the keyless admin console and
@@ -2301,6 +2366,41 @@ func (s *Server) postThreadPinned(w http.ResponseWriter, r *http.Request) {
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
+// thread's messages still arrive normally. It never affects anyone else, and
+// nobody else can see it.
+func (s *Server) postThreadMuted(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ Muted bool `json:"muted"`
+ }
+ 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 err := s.db.setThreadMuted(uid, threadID, body.Muted); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
// postThreadRead advances the caller's read watermark for a thread — "read
// here, read everywhere". last_read_at is the time of the newest message the
// device has shown; when the mark advances, it fans out over WebSocket to the
diff --git a/miniapps.go b/miniapps.go
index 0138664..8973d2d 100644
--- a/miniapps.go
+++ b/miniapps.go
@@ -308,6 +308,7 @@ func (s *Server) pushScheduled(blob, threadID string, id, at int64) {
for _, u := range members {
set[u] = true
}
+ s.dropMutedRecipients(threadID, set) // mute wins even over a reminder
payload, _ := json.Marshal(map[string]string{"blob": blob, "thread": threadID})
s.sendToRecipients(payload, "sched", "", true, set)
s.apnsFanOut(threadID, blob, id, at, "", true, set)
diff --git a/mute_test.go b/mute_test.go
new file mode 100644
index 0000000..ae639cb
--- /dev/null
+++ b/mute_test.go
@@ -0,0 +1,149 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+// Muting a chat is a per-user marker synced via getThreads, and its whole
+// server-side effect is subtractive: the push fan-out drops every one of the
+// muter's devices for that thread. Nobody else's list or pushes change.
+func TestThreadMuted(t *testing.T) {
+ ts, srv := newTestServer(t)
+
+ tokA, uidA, _ := joinedMember(t, srv, "Alice")
+ tokB, uidB, _ := joinedMember(t, srv, "Bob")
+ _ = tokB
+ tid, err := srv.db.createThread("group", "", []ThreadMemberKey{
+ {UserID: uidA, Wrapped: "wa"}, {UserID: uidB, Wrapped: "wb"},
+ })
+ if err != nil {
+ t.Fatalf("createThread: %v", err)
+ }
+
+ post := func(path, body string) int {
+ resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body))
+ if err != nil {
+ t.Fatalf("post %s: %v", path, err)
+ }
+ resp.Body.Close()
+ return resp.StatusCode
+ }
+ mutedForAlice := func() bool {
+ resp, err := http.Get(ts.URL + "/api/threads?token=" + tokA)
+ if err != nil {
+ t.Fatalf("getThreads: %v", err)
+ }
+ defer resp.Body.Close()
+ var out struct {
+ Threads []Thread `json:"threads"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ for _, th := range out.Threads {
+ if th.ID == tid {
+ return th.Muted
+ }
+ }
+ t.Fatalf("thread %q missing from Alice's list", tid)
+ return false
+ }
+
+ if mutedForAlice() {
+ t.Fatal("fresh thread should not be muted")
+ }
+ if code := post("/api/threads/"+tid+"/muted", `{"token":"`+tokA+`","muted":true}`); code != 200 {
+ t.Fatalf("mute: got %d", code)
+ }
+ if !mutedForAlice() {
+ t.Fatal("mute should stick and sync via getThreads")
+ }
+
+ // The push recipient set loses the muter — and only the muter.
+ set := map[string]bool{uidA: true, uidB: true}
+ srv.dropMutedRecipients(tid, set)
+ if set[uidA] || !set[uidB] {
+ t.Fatalf("muted member should drop from push recipients, others stay: %v", set)
+ }
+
+ if code := post("/api/threads/"+tid+"/muted", `{"token":"`+tokA+`","muted":false}`); code != 200 {
+ t.Fatalf("unmute: got %d", code)
+ }
+ if mutedForAlice() {
+ t.Fatal("unmute should clear the marker")
+ }
+ set = map[string]bool{uidA: true, uidB: true}
+ srv.dropMutedRecipients(tid, set)
+ if !set[uidA] {
+ t.Fatal("after unmuting, pushes should reach the member again")
+ }
+
+ // Non-members can't mute a thread they're not in.
+ tokC, _, _ := joinedMember(t, srv, "Carol")
+ if code := post("/api/threads/"+tid+"/muted", `{"token":"`+tokC+`","muted":true}`); code != 403 {
+ t.Fatalf("non-member mute should be 403, got %d", code)
+ }
+}
+
+// The prefs blob (muted people) round-trips opaquely and CASes like the
+// nicknames blob: a write from a stale version 409s with the current version
+// instead of clobbering another device's write.
+func TestPrefsBlobRoundTrip(t *testing.T) {
+ ts, srv := newTestServer(t)
+ tok, _, _ := joinedMember(t, srv, "Alice")
+
+ get := func() (string, int64) {
+ resp, err := http.Get(ts.URL + "/api/prefs?token=" + tok)
+ if err != nil {
+ t.Fatalf("get prefs: %v", err)
+ }
+ defer resp.Body.Close()
+ var out struct {
+ PrefsBlob string `json:"prefs_blob"`
+ Version int64 `json:"version"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ return out.PrefsBlob, out.Version
+ }
+ post := func(body string) (int, int64) {
+ resp, err := http.Post(ts.URL+"/api/prefs", "application/json", strings.NewReader(body))
+ if err != nil {
+ t.Fatalf("post prefs: %v", err)
+ }
+ defer resp.Body.Close()
+ var out struct {
+ Version int64 `json:"version"`
+ }
+ _ = json.NewDecoder(resp.Body).Decode(&out)
+ return resp.StatusCode, out.Version
+ }
+
+ if blob, v := get(); blob != "" || v != 0 {
+ t.Fatalf("fresh prefs should be empty at version 0, got %q v%d", blob, v)
+ }
+ if code, v := post(`{"token":"` + tok + `","prefs_blob":"sealed1","version":0}`); code != 200 || v != 1 {
+ t.Fatalf("first write: got %d v%d", code, v)
+ }
+ if blob, v := get(); blob != "sealed1" || v != 1 {
+ t.Fatalf("round trip: got %q v%d", blob, v)
+ }
+ // Stale base: another device already wrote v1, so a writer holding v0 loses.
+ if code, v := post(`{"token":"` + tok + `","prefs_blob":"stale","version":0}`); code != 409 || v != 1 {
+ t.Fatalf("stale write should 409 with current version, got %d v%d", code, v)
+ }
+ if blob, _ := get(); blob != "sealed1" {
+ t.Fatalf("stale write must not clobber, got %q", blob)
+ }
+ // Versionless write (pre-versioning client) stays unconditional.
+ if code, _ := post(`{"token":"` + tok + `","prefs_blob":"sealed2"}`); code != 200 {
+ t.Fatalf("versionless write: got %d", code)
+ }
+ if blob, v := get(); blob != "sealed2" || v != 2 {
+ t.Fatalf("after versionless write: got %q v%d", blob, v)
+ }
+}
diff --git a/push.go b/push.go
index ae02f55..f65f845 100644
--- a/push.go
+++ b/push.go
@@ -227,6 +227,7 @@ func (s *Server) pushNewMessage(blob, threadID, senderToken string, id, at int64
for _, u := range members {
set[u] = true
}
+ s.dropMutedRecipients(threadID, set)
// id/at ride along so the service worker can file the row straight into
// the page's local message store (#457 phase 2) — opening from a
// notification then paints with the message already in place. Additive:
@@ -241,6 +242,20 @@ func (s *Server) pushNewMessage(blob, threadID, senderToken string, id, at int64
s.apnsFanOut(threadID, blob, id, at, senderToken, true, set)
}
+// dropMutedRecipients removes members who muted the thread from a push
+// recipient set — a mute means no buzz on any device, over either transport.
+// The messages themselves still flow; only notifications stop.
+func (s *Server) dropMutedRecipients(threadID string, set map[string]bool) {
+ muted, err := s.db.mutedUserIDs(threadID)
+ if err != nil {
+ log.Println("push: muted members:", err)
+ return // best-effort: on error, prefer a spurious buzz over a lost one
+ }
+ for u := range muted {
+ delete(set, u)
+ }
+}
+
// sendTestPush delivers a generic "it works" notification to every subscriber,
// bypassing the message flow (and ignoring active state, so it always lands).
// It covers BOTH transports: Web Push (browsers) and APNs (native iOS, via the
diff --git a/web/pages/index.html b/web/pages/index.html
index 45b4bac..65dee24 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -196,6 +196,7 @@
<button class="menu-item" id="thread-members" role="menuitem">View members</button>
<button class="menu-item" id="thread-apps" role="menuitem">Add apps</button>
<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-leave" role="menuitem">Leave group</button>
</div>
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 232c64f..cef6f01 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -705,7 +705,7 @@ export const app = defineApp({
if (introText) items.unshift({ id: "chatapp-intro", intro: true, text: introText, vrev: "intro|" + introText });
keyedList(feed, items, {
key: (m) => m.id,
- rev: (m) => m.vrev,
+ rev: (m) => m.vrev + mutedRev(m, ctx),
srev: (m) => m.srev,
render: (m) => renderItem(m, ctx),
update: (node, m) => patchBubble(node, m, ctx),
@@ -786,6 +786,18 @@ export const app = defineApp({
},
});
+// Muted-message stubs the user tapped open this session (by sid). Deliberately
+// not persisted: reopening the app re-folds — a mute means muted.
+const revealedMuted = new Set();
+
+// Muted-person folding is render-level state, not folded state — mix it into
+// the keyed-list rev so muting, unmuting, or revealing rebuilds exactly the
+// rows it changes and nothing else (m0 = folded stub, m1 = revealed).
+function mutedRev(m, ctx) {
+ if (!m.uid || !ctx || m.uid === ctx.me || !host || !host.mutedPerson || !host.mutedPerson(m.uid)) return "";
+ return revealedMuted.has(m.sid) ? "|m1" : "|m0";
+}
+
// ---- bubbles: the original feed's exact markup ------------------------------
// renderItem builds one feed row inside a display:contents wrapper, so the
@@ -835,6 +847,31 @@ function renderItem(m, ctx) {
return item;
}
+ // A muted member's message (issue #578) folds to a quiet stub — visible and
+ // tappable rather than silently gone, because the honest framing of a mute
+ // is "you stop seeing them", not "they stop existing". Reveal is per-message
+ // and per-session. Your own messages never fold.
+ if (m.uid && m.uid !== ctx.me && host && host.mutedPerson && host.mutedPerson(m.uid) && !revealedMuted.has(m.sid)) {
+ const stub = document.createElement("div");
+ stub.className = "muted-stub";
+ const label = document.createElement("span");
+ label.className = "muted-stub-label";
+ label.textContent = "Muted message";
+ stub.appendChild(label);
+ const show = document.createElement("button");
+ show.type = "button";
+ show.className = "muted-stub-show";
+ show.textContent = "Show";
+ show.setAttribute("aria-label", "Show muted message");
+ show.onclick = () => {
+ revealedMuted.add(m.sid);
+ item.replaceWith(renderItem(m, ctx)); // re-render this one row in place
+ };
+ stub.appendChild(show);
+ item.appendChild(stub);
+ return item;
+ }
+
// Mirror the original feed's attribution (ownMessage): prefer the inline uid,
// but fall back to a screen-name match so your own legacy (uid-less) messages
// still render as yours — right-aligned, name hidden in group runs, editable.
@@ -1234,6 +1271,15 @@ function openMessagePicker(m, ctx, anchor) {
if (confirm("Delete this message for everyone? This can't be undone.") && host) host.del(ctx.threadId, m.sid, attachmentBlobIds(m));
});
}
+ // Mute the sender (issue #578) — their messages fold to a quiet stub. Needs
+ // a uid to key on (legacy uid-less messages can't be muted per-person).
+ if (!mine && m.uid && host && host.mutedPerson && host.setPersonMuted) {
+ const who = (host.memberName && host.memberName(m.uid)) || m.name || "them";
+ const isMuted = host.mutedPerson(m.uid);
+ action(icon("mute"), isMuted ? `Unmute ${who}` : `Mute ${who}`, () => {
+ host.setPersonMuted(m.uid, !isMuted);
+ });
+ }
for (const act of messageActionsFor({ text: m.text, from: m.name })) {
const b = document.createElement("button");
b.className = "picker-action";
@@ -1336,6 +1382,16 @@ function sheetActions(m, ctx) {
},
});
}
+ // Mute the sender (issue #578) — same gate as the desktop picker.
+ if (!mine && m.uid && host && host.mutedPerson && host.setPersonMuted) {
+ const who = (host.memberName && host.memberName(m.uid)) || m.name || "them";
+ const isMuted = host.mutedPerson(m.uid);
+ acts.push({
+ label: isMuted ? `Unmute ${who}` : `Mute ${who}`,
+ icon: icon("mute"),
+ run: () => host.setPersonMuted(m.uid, !isMuted),
+ });
+ }
return acts;
}
@@ -3595,7 +3651,7 @@ function buildSkeleton(el, ctx) {
const threadKeyed = (container, items) =>
keyedList(container, items, {
key: (m) => m.id,
- rev: (m) => m.vrev,
+ rev: (m) => m.vrev + mutedRev(m, ctx),
srev: (m) => m.srev,
render: (m) => renderItem(m, ctx),
update: (node, m) => patchBubble(node, m, ctx),
diff --git a/web/static/shell.js b/web/static/shell.js
index 4bbe51d..34a4611 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -761,6 +761,16 @@ const ICON_PIN =
'<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
'<path d="M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 ' +
'3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z"></path></svg>';
+// Slashed bell marking muted chats in the thread list — outline stroke like
+// ICON_COPY so the slash reads clearly at ~12px.
+const ICON_MUTE =
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
+ '<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>' +
+ '<path d="M18.63 13A17.9 17.9 0 0 1 18 8"></path>' +
+ '<path d="M6.26 6.26A5.86 5.86 0 0 0 6 8c0 7-3 9-3 9h14"></path>' +
+ '<path d="M18 8a6 6 0 0 0-9.33-5"></path>' +
+ '<line x1="1" y1="1" x2="23" y2="23"></line></svg>';
// Overlapping pages, for the "Copy" action in the mobile long-press menu.
const ICON_COPY =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" ' +
@@ -881,6 +891,7 @@ const unread = {}; // thread id -> bool
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
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).
@@ -1095,6 +1106,12 @@ async function boot() {
const nickLoad = loadNicknames().then(() => {
if (document.getElementById("thread-list")) refreshNamesUI();
});
+ // Muted people ride the same sealed-blob shape; a late arrival just means
+ // a few bubbles unfold on the repaint that follows.
+ loadModPrefs().then(() => {
+ syncThreadKeysToSW();
+ renderActiveSurfaces();
+ });
await Promise.all([setupInstanceKey(), loadThreads(), Promise.race([nickLoad, new Promise((res) => setTimeout(res, 3000))])]);
await bootstrapBranding(); // sealed instances: open the branding blob (needs the instance key for first-time B)
await loadTiers(); // before the re-fold: gated groups unwrap via tier keys
@@ -1698,6 +1715,93 @@ async function resolveRename(uid, keep) {
return ok;
}
+// ---- Muted people (issue #578) ----------------------------------------------
+// Who I've muted, as a private set synced across my devices via the sealed
+// prefs blob (/api/prefs) — the same seal-to-my-own-key + compare-and-swap
+// shape as nicknames. Fully client-side: a muted person's messages fold to a
+// quiet stub and never notify, but nothing changes for them and the server
+// never learns who's muted.
+let mutedPeople = new Set();
+const mutedOps = new Map(); // uid -> true (mute) | false (unmute), pending sync
+let mutedRetryTimer = 0;
+let prefsVersion = 0;
+
+async function fetchServerPrefs() {
+ if (!session.userPriv) return null;
+ try {
+ const r = await fetch(`/api/prefs?token=${encodeURIComponent(session.token)}`);
+ if (!r.ok) return null;
+ const body = await r.json();
+ prefsVersion = body.version || 0;
+ if (!body.prefs_blob) return {};
+ const obj = await openSealed(body.prefs_blob, session.userPriv);
+ return obj || {};
+ } catch {
+ return null;
+ }
+}
+
+async function loadModPrefs() {
+ const server = await fetchServerPrefs();
+ if (server) mutedPeople = new Set(server.mutedPeople || []);
+}
+
+// Read-modify-write with the same serialized chain + retry the nicknames blob
+// uses: replay only this device's pending ops over a fresh server read.
+let prefsSaveChain = Promise.resolve(true);
+function saveModPrefs() {
+ prefsSaveChain = prefsSaveChain.then(saveModPrefsNow, saveModPrefsNow);
+ return prefsSaveChain;
+}
+
+async function saveModPrefsNow() {
+ if (!session.userPriv || !session.userPub) return true;
+ if (!mutedOps.size) return true;
+ const included = [...mutedOps.entries()];
+ const base = await fetchServerPrefs();
+ if (!base) return queuePrefsRetry();
+ const baseVersion = prefsVersion;
+ const merged = new Set(base.mutedPeople || []);
+ for (const [uid, on] of included) {
+ if (on) merged.add(uid);
+ else merged.delete(uid);
+ }
+ try {
+ const blob = await sealForPub(session.userPub, { v: 1, mutedPeople: [...merged] });
+ const r = await fetch("/api/prefs", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, prefs_blob: blob, version: baseVersion }),
+ });
+ if (!r.ok) return queuePrefsRetry();
+ prefsVersion = ((await r.json()) || {}).version || baseVersion + 1;
+ mutedPeople = merged; // adopt another device's writes too
+ for (const [uid, on] of included) if (mutedOps.get(uid) === on) mutedOps.delete(uid);
+ return true;
+ } catch {
+ return queuePrefsRetry();
+ }
+}
+
+function queuePrefsRetry() {
+ clearTimeout(mutedRetryTimer);
+ mutedRetryTimer = setTimeout(() => {
+ if (mutedOps.size) saveModPrefs();
+ }, 5000);
+ return false;
+}
+
+// Mute or unmute a person, applying locally at once (the sync is eventual).
+function setPersonMuted(uid, on) {
+ if (!uid || uid === myUserId) return;
+ if (on) mutedPeople.add(uid);
+ else mutedPeople.delete(uid);
+ mutedOps.set(uid, on);
+ saveModPrefs();
+ syncThreadKeysToSW(); // keep the SW's muted-people set current
+ renderActiveSurfaces(); // fold/unfold their bubbles in the open thread
+}
+
function rosterLabel(userId) {
const e = roster.find((x) => x.user_id === userId);
return displayNameFor(nicknames[userId], e && e.label) || "Someone";
@@ -2185,6 +2289,7 @@ async function applyThreads(r) {
threadName[t.id] = await displayName(t);
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
// 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]) {
@@ -2265,6 +2370,11 @@ function syncThreadKeysToSW() {
if (n) byUid[e.user_id] = n;
}
idbSet("memberNames", { byUid, renames: nicknameRenames() }).catch(() => {});
+ // Mute state for the SW's own notification gate. The server already skips
+ // pushes for muted THREADS; muted PEOPLE are invisible to it by design, so
+ // the sender check after decryption is the only place that one can run.
+ const mutedThreads = Object.keys(muted).filter((id) => muted[id]);
+ idbSet("muted", { threads: mutedThreads, people: [...mutedPeople] }).catch(() => {});
}
// The frontend build this page loaded with (server-injected into the HTML).
@@ -2737,6 +2847,10 @@ function renderApp() {
closeTMenu();
togglePin();
};
+ document.getElementById("thread-mute").onclick = () => {
+ closeTMenu();
+ toggleMute();
+ };
document.getElementById("thread-close").onclick = () => {
closeTMenu();
closeChat();
@@ -2927,8 +3041,18 @@ function renderThreadList() {
pin.innerHTML = ICON_PIN; // static in-code SVG (safe)
meta.appendChild(pin);
}
+ if (muted[t.id]) {
+ const mu = document.createElement("span");
+ mu.className = "thread-row-mute";
+ mu.innerHTML = ICON_MUTE; // static in-code SVG (safe)
+ mu.setAttribute("role", "img");
+ mu.setAttribute("aria-label", "Muted");
+ meta.appendChild(mu);
+ }
+ // A muted chat never escalates: no unread dot (the mute icon marks the
+ // state instead — state is never color alone).
const dot = document.createElement("span");
- dot.className = "unread-dot" + (unread[t.id] ? " on" : "");
+ dot.className = "unread-dot" + (unread[t.id] && !muted[t.id] ? " on" : "");
meta.appendChild(dot);
row.appendChild(meta);
@@ -3240,6 +3364,7 @@ function paintThreadHeader(id) {
if (el) el.style.display = isGroup ? "" : "none";
}
document.getElementById("thread-pin").textContent = pinned[id] ? "Unpin chat" : "Pin chat";
+ document.getElementById("thread-mute").textContent = muted[id] ? "Unmute chat" : "Mute chat";
paintViewToggle(id);
paintHeaderAvatar(id);
}
@@ -4436,7 +4561,7 @@ setChatAppHost({
}
return null;
},
- icons: { reply: ICON_REPLY, edit: ICON_EDIT, react: ICON_REACT, trash: ICON_TRASH, x: ICON_X, play: ICON_PLAY, copy: ICON_COPY },
+ icons: { reply: ICON_REPLY, edit: ICON_EDIT, react: ICON_REACT, trash: ICON_TRASH, x: ICON_X, play: ICON_PLAY, copy: ICON_COPY, mute: ICON_MUTE },
// Helper-proposed one-tap action chips ("Switch to dark" …): whitelisted,
// labelled, and run by the host — the app just mounts the returned row.
actionChips: (actions) => {
@@ -4456,6 +4581,11 @@ setChatAppHost({
registerNative: (handlers) => {
chatAppNative = handlers;
},
+ // Moderation (issue #578): the muted-people set lives here in the shell
+ // (sealed prefs blob, synced across devices); the app folds a muted member's
+ // messages into a quiet stub and offers Mute/Unmute in the message menu.
+ mutedPerson: (uid) => mutedPeople.has(uid),
+ setPersonMuted: (uid, on) => setPersonMuted(uid, on),
});
// The lab app's native-composer handlers (registered via the bridge when the
@@ -8321,6 +8451,29 @@ function viewProfile(uid) {
row.appendChild(msg);
card.appendChild(row);
}
+
+ // Mute them (issue #578) — their messages in shared chats fold to a quiet
+ // stub and never notify. Private: they don't find out, and the server can't
+ // read the muted set either. Honest copy: they stop appearing to YOU.
+ if (uid !== myUserId) {
+ const modRow = document.createElement("div");
+ modRow.className = "row";
+ modRow.style.cssText = "justify-content:center;margin-top:10px";
+ const muteBtn = document.createElement("button");
+ muteBtn.className = "secondary";
+ const paintMute = () => {
+ muteBtn.textContent = mutedPeople.has(uid)
+ ? `Unmute ${displayNameFor(nicknames[uid], e.label) || "them"}`
+ : `Mute ${displayNameFor(nicknames[uid], e.label) || "them"}`;
+ };
+ paintMute();
+ muteBtn.onclick = () => {
+ setPersonMuted(uid, !mutedPeople.has(uid));
+ paintMute();
+ };
+ modRow.appendChild(muteBtn);
+ card.appendChild(modRow);
+ }
}
// A DM partner renamed themself while I hold my own name for them (#577): a
@@ -8539,6 +8692,23 @@ async function closeChat() {
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.
+function toggleMute() {
+ const id = activeId;
+ if (!id) return;
+ muted[id] = !muted[id];
+ fetch(`/api/threads/${encodeURIComponent(id)}/muted`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, muted: muted[id] }),
+ }).catch(() => {});
+ document.getElementById("thread-mute").textContent = muted[id] ? "Unmute chat" : "Mute chat";
+ renderThreadList();
+ syncThreadKeysToSW(); // the SW's own muted check (belt to the server's braces)
+}
+
// Pin/unpin the open chat: pinned chats float to the top of the list. Per-user,
// synced across your devices.
function togglePin() {
diff --git a/web/static/style.css b/web/static/style.css
index 740600d..6034d7a 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -2164,6 +2164,33 @@ input.filter-check:focus-visible { outline: 2px solid var(--accent); outline-off
.thread-row-time { font-size: 11px; font-weight: 400; color: var(--muted); white-space: nowrap; }
.thread-row-pin { color: var(--muted); flex: none; }
.thread-row-pin svg { width: 15px; height: 15px; display: block; transform: rotate(45deg); }
+.thread-row-mute { color: var(--muted); flex: none; }
+.thread-row-mute svg { width: 14px; height: 14px; display: block; }
+/* A muted member's folded message (issue #578): a quiet, obviously-interactive
+ stub occupying the bubble's place — never a silent gap in the conversation. */
+.muted-stub {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 14px;
+ border-radius: 16px;
+ border: 1px dashed var(--border);
+ color: var(--muted);
+ font-size: 13px;
+ font-style: italic;
+}
+.muted-stub-show {
+ all: unset;
+ cursor: pointer;
+ font-style: normal;
+ font-weight: 600;
+ font-size: 13px;
+ color: var(--accent);
+ padding: 4px 8px; /* padding keeps the tap target comfortably ≥24px tall */
+ border-radius: 6px;
+}
+.muted-stub-show:hover { text-decoration: underline; }
+.muted-stub-show:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.unread-dot { width: 8px; height: 8px; border-radius: 50%; background: transparent; flex: none; }
.unread-dot.on { background: var(--accent); }
/* Mobile list treatment (à la Messages/WhatsApp): rows run edge-to-edge, the active
diff --git a/web/static/sw.js b/web/static/sw.js
index ad5381c..b211724 100644
--- a/web/static/sw.js
+++ b/web/static/sw.js
@@ -164,6 +164,12 @@ async function handlePush(event) {
// Real message: skip if the app is open/active (you're already seeing it).
if (await appIsActive()) return;
try {
+ // Mute (issue #578): muted THREADS are already skipped by the server's
+ // fan-out — this local check just covers pushes already in flight when
+ // the mute landed. Muted PEOPLE are checked after decryption below (the
+ // server can't: who's muted is sealed client-side).
+ const mutedPrefs = (await idbGet("muted")) || {};
+ if (Array.isArray(mutedPrefs.threads) && data.thread && mutedPrefs.threads.includes(data.thread)) return;
// Per-thread keys (id -> {key, name, kind}) the page syncs to IndexedDB.
const threads = (await idbGet("threadKeys")) || {};
// Names as this member shows them (#577): uid → display name, plus the
@@ -173,6 +179,8 @@ async function handlePush(event) {
const t = data.thread && threads[data.thread];
if (data.blob && t && t.key) {
const msg = await decryptBlob(data.blob, t.key);
+ // A muted person never notifies — from any thread, for any event type.
+ if (msg.uid && Array.isArray(mutedPrefs.people) && mutedPrefs.people.includes(msg.uid)) return;
if (msg.type === "reaction") {
// A reaction event: only the reacted-to message's author is shown a
// notification. The author's uid (targetUid) rides inside the