eleven / ocho Public

mac: notification banners match iOS — never-empty body, resolved sender names, muted suppression (#1020)

A textless message (an app event like a Battleships move) shipped an
empty communication-banner body, which macOS re-lays-out as app name +
sender with the message dropped entirely — the baffling 'Eleven / James'
banner. Mirror the iOS extension's composition exactly:

- body falls back to '📎 Attachment(s)' / 'New message', never empty
- senders resolve through per-viewer names (#577), then the payload
  snapshot — reactions too
- muted threads and muted people never banner (#578); local
  notifications can drop outright where the NSE ships a passive entry
- threadIdentifier groups banners by conversation like iOS
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 75b99e56b36aaf247607345212f0ef2068b0caf3 parent af23306
2 files changed, +44 −7
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +6 −0
  • ios/LChatMac/NativeChatView.swift +38 −7
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index a1952a5..5dee10e 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -36,6 +36,12 @@ public final class ThreadStore: ObservableObject {
// view (my name for them → their published name) display surfaces read.
@Published public private(set) var nicknames: [String: NicknameEntry] = [:]
private var displayLabels: [String: String] = [:]
+
+ /// A copy of the resolved sender-name map (uid → the name THIS member
+ /// shows for them) for surfaces that resolve senders off the main actor —
+ /// the Mac's notification banners, mirroring the iOS extension's parked
+ /// `entry.names` lookup.
+ public var senderLabels: [String: String] { displayLabels }
private var nicknamesVersion: Int64 = 0
// Ops this device has made that the server copy may not have yet
// (uid → entry, nil = cleared): saves re-read the server blob and replay
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 367695a..e53fb85 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -1479,47 +1479,78 @@ struct NativeChatView: View {
private func notifyIfBackgrounded(_ frame: ChatSocket.Frame) {
guard !NSApp.isActive,
let tid = frame.thread_id, let blob = frame.blob,
- let thread = store.threads.first(where: { $0.id == tid })
+ let thread = store.threads.first(where: { $0.id == tid }),
+ !thread.isMuted
else { return }
let myUserId = store.myUserId
let instanceID = instance.id
let origin = instance.origin
+ let labels = store.senderLabels
+ let mutedPeople = store.mutedPeople
// The AES open runs detached — this fires for every message on the
// instance socket while backgrounded; posting the banner is
// thread-safe.
Task.detached(priority: .utility) {
guard let key = try? GroupKey(base64url: thread.threadKeyB64),
let p = try? key.decryptJSON(MessagePayload.self, blob: blob),
- p.uid != myUserId
+ p.uid != myUserId,
+ // A muted person never buzzes (#578) — their uid rides inside
+ // the ciphertext, so only this post-decrypt point can know.
+ // Local banners can drop outright where the iOS extension
+ // must ship a passive entry.
+ !(p.uid.map { mutedPeople.contains($0) } ?? false)
else { return }
+ // Senders resolve like the iOS extension: the name THIS member
+ // shows for them (per-viewer names, #577), then the payload's
+ // snapshot. nil = no real person to show (app/system events).
+ let resolvedName: String? = {
+ if let uid = p.uid, let named = labels[uid], !named.isEmpty { return named }
+ if let n = p.name, !n.isEmpty { return n }
+ return nil
+ }()
+ let sender = resolvedName ?? "Someone"
let content = UNMutableNotificationContent()
content.title = thread.title
+ content.threadIdentifier = tid
if p.isReactionEvent {
// Reactions notify only the reacted-to message's author — and
// the Mac posts local notifications, so unlike the iOS NSE it
// can drop the rest outright. Un-reacts and reactions to
// legacy uid-less messages notify no one.
guard p.on == true, let target = p.targetUid, target == myUserId else { return }
- let sender = p.name ?? "Someone"
let emoji = p.emoji ?? ""
content.body = thread.kind == "group"
? "\(sender) reacted \(emoji) to your message"
: "Reacted \(emoji) to your message"
- } else {
- content.body = previewText(kind: thread.kind, payload: p)
+ content.sound = .default
+ content.userInfo = ["instance": instanceID, "thread": tid]
+ try? await UNUserNotificationCenter.current().add(
+ UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
+ return
+ }
+ // The banner text mirrors the iOS extension exactly — and it must
+ // never be empty: macOS re-lays-out a communication banner with no
+ // body as "app name + sender", dropping the message entirely (a
+ // textless app event rendered as the baffling "Eleven / James").
+ var text = markdownPlainText(p.text ?? "")
+ if text.isEmpty {
+ let n = p.attachments?.count ?? 0
+ text = n > 0 ? (n == 1 ? "📎 Attachment" : "📎 \(n) attachments") : "New message"
}
+ // In a DM the title already names the sender; groups prefix them.
+ content.body = thread.kind == "group" ? "\(sender): \(text)" : text
content.sound = .default
content.userInfo = ["instance": instanceID, "thread": tid]
// Sender's face on the banner where possible — same restyle the
// iOS extension applies (CommunicationNotification); a build
// without the communication entitlement just keeps the banner.
var deliver: UNNotificationContent = content
- if let name = p.name, !name.isEmpty,
+ if let name = resolvedName,
let styled = CommunicationNotification.decorate(
content, senderName: name, senderUID: p.uid,
origin: origin, conversationID: tid,
groupName: thread.kind == "group" ? thread.title : nil,
- messageText: previewText(kind: "dm", payload: p)) {
+ messageText: text) {
deliver = styled
}
try? await UNUserNotificationCenter.current().add(