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>
28 files changed,
+1268
−69
changelog/2026-07-12-mute.md+6 −0db.go+85 −1ios/LChat/Views/NativeChatScreen.swift+52 −14ios/LChat/Views/ThreadListCollection.swift+18 −2ios/LChat/Views/ThreadListView.swift+2 −1ios/LChat/Views/ThreadRow.swift+9 −1ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+19 −0ios/LChatCore/Sources/LChatCore/LChatAPI.swift+41 −0ios/LChatCore/Sources/LChatCore/MemberProfileSheet.swift+14 −0ios/LChatCore/Sources/LChatCore/MessageBubble.swift+59 −1ios/LChatCore/Sources/LChatCore/MessageRightClick.swift+3 −0ios/LChatCore/Sources/LChatCore/ModPrefs.swift+32 −0ios/LChatCore/Sources/LChatCore/Models.swift+7 −0ios/LChatCore/Sources/LChatCore/NativeFeedModelCache.swift+14 −0ios/LChatCore/Sources/LChatCore/SharedThreadKeys.swift+77 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+176 −3ios/LChatMac/NativeChatView.swift+15 −1ios/LChatMac/NativeFeedView.swift+86 −38ios/LChatNSE/NotificationService.swift+22 −3main.go+100 −0miniapps.go+1 −0mute_test.go+149 −0push.go+15 −0web/pages/index.html+1 −0web/static/apps/chat.js+58 −2web/static/shell.js+172 −2web/static/style.css+27 −0web/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 |