eleven / messenger Public

ios+mac: initials monogram on notification banners when no photo is cached (#472)

A communication notification whose INPerson has no image falls back to
the system's gray silhouette — bland, and nothing like the app. Senders
without a cached profile photo (or uid-less legacy payloads) now get the
same monogram the app draws for them: first initial, white, over the
name-hashed 8-colour palette. MonogramImage keeps initial/palette rules
in lockstep with ios/LChat/Views/Avatar.swift, pinned by tests.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8a2db71b79fa31e573c41ce4b3933a414df2ba33 parent bf6fd10
3 files changed, +116 −0
  • ios/LChatCore/Sources/LChatCore/CommunicationNotification.swift +4 −0
  • ios/LChatCore/Sources/LChatCore/MonogramImage.swift +81 −0
  • ios/LChatCore/Tests/LChatCoreTests/MonogramImageTests.swift +31 −0
diff --git a/ios/LChatCore/Sources/LChatCore/CommunicationNotification.swift b/ios/LChatCore/Sources/LChatCore/CommunicationNotification.swift
index 4a9ae46..82a07de 100644
--- a/ios/LChatCore/Sources/LChatCore/CommunicationNotification.swift
+++ b/ios/LChatCore/Sources/LChatCore/CommunicationNotification.swift
@@ -23,9 +23,13 @@ public enum CommunicationNotification {
senderName: String, senderUID: String?,
origin: String, conversationID: String,
groupName: String?, messageText: String) -> UNNotificationContent? {
+ // The sender's cached photo, else the same initials-on-colour monogram
+ // the app draws for them — never the system's gray silhouette.
var image: INImage?
if let uid = senderUID, let data = SharedAvatarCache.imageData(origin: origin, uid: uid) {
image = INImage(imageData: data)
+ } else if let data = MonogramImage.pngData(name: senderName) {
+ image = INImage(imageData: data)
}
// The handle/customIdentifier scope the person to their instance —
// the same uid on two instances is two different people.
diff --git a/ios/LChatCore/Sources/LChatCore/MonogramImage.swift b/ios/LChatCore/Sources/LChatCore/MonogramImage.swift
new file mode 100644
index 0000000..efbad29
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/MonogramImage.swift
@@ -0,0 +1,81 @@
+import Foundation
+#if canImport(UIKit)
+import UIKit
+#elseif canImport(AppKit)
+import AppKit
+#endif
+
+// The initials fallback for a notification's sender image: without one, a
+// communication notification shows the system's gray silhouette — so senders
+// with no profile photo get the SAME monogram the app draws for them (Avatar
+// on iOS, the Mac's circles): first initial, white, over a colour hashed from
+// the name. Keep initial(for:)/paletteIndex(for:) in lockstep with
+// ios/LChat/Views/Avatar.swift so a person's banner face matches their in-app
+// face. Rendered square — the notification pipeline circle-crops it.
+public enum MonogramImage {
+ /// The uppercased first character, "?" for an all-whitespace name —
+ /// Avatar.initial verbatim.
+ static func initial(for name: String) -> String {
+ let s = String(name.trimmingCharacters(in: .whitespaces).prefix(1)).uppercased()
+ return s.isEmpty ? "?" : s
+ }
+
+ /// djb2 over the name's UTF-8, mod the 8-colour palette — Avatar.color verbatim.
+ static func paletteIndex(for name: String) -> Int {
+ var h = 5381
+ for b in name.utf8 { h = (h &* 33) &+ Int(b) }
+ return abs(h) % 8
+ }
+
+ #if canImport(UIKit) || canImport(AppKit)
+ // The concrete twins of Avatar's SwiftUI palette (.blue, .purple, …).
+ #if canImport(UIKit)
+ private static let palette: [UIColor] = [
+ .systemBlue, .systemPurple, .systemPink, .systemOrange,
+ .systemGreen, .systemTeal, .systemIndigo, .systemRed,
+ ]
+ #else
+ private static let palette: [NSColor] = [
+ .systemBlue, .systemPurple, .systemPink, .systemOrange,
+ .systemGreen, .systemTeal, .systemIndigo, .systemRed,
+ ]
+ #endif
+
+ /// PNG bytes of the monogram tile, or nil if rendering fails.
+ public static func pngData(name: String, size: CGFloat = 256) -> Data? {
+ let color = palette[paletteIndex(for: name)]
+ let text = initial(for: name) as NSString
+ let rect = CGRect(x: 0, y: 0, width: size, height: size)
+ #if canImport(UIKit)
+ let attrs: [NSAttributedString.Key: Any] = [
+ .font: UIFont.systemFont(ofSize: size * 0.42, weight: .semibold),
+ .foregroundColor: UIColor.white,
+ ]
+ let img = UIGraphicsImageRenderer(size: rect.size).image { ctx in
+ color.setFill()
+ ctx.fill(rect)
+ let s = text.size(withAttributes: attrs)
+ text.draw(at: CGPoint(x: (size - s.width) / 2, y: (size - s.height) / 2),
+ withAttributes: attrs)
+ }
+ return img.pngData()
+ #else
+ let attrs: [NSAttributedString.Key: Any] = [
+ .font: NSFont.systemFont(ofSize: size * 0.42, weight: .semibold),
+ .foregroundColor: NSColor.white,
+ ]
+ let img = NSImage(size: rect.size)
+ img.lockFocus()
+ color.setFill()
+ rect.fill()
+ let s = text.size(withAttributes: attrs)
+ text.draw(at: CGPoint(x: (size - s.width) / 2, y: (size - s.height) / 2),
+ withAttributes: attrs)
+ img.unlockFocus()
+ guard let tiff = img.tiffRepresentation,
+ let rep = NSBitmapImageRep(data: tiff) else { return nil }
+ return rep.representation(using: .png, properties: [:])
+ #endif
+ }
+ #endif
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/MonogramImageTests.swift b/ios/LChatCore/Tests/LChatCoreTests/MonogramImageTests.swift
new file mode 100644
index 0000000..bc72fd5
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/MonogramImageTests.swift
@@ -0,0 +1,31 @@
+import XCTest
+@testable import LChatCore
+
+// 🤖 Pins the notification monogram to the Avatar view's rules (initial +
+// djb2 palette pick) so a person's banner face can't drift from their in-app
+// face, and smoke-tests the render.
+final class MonogramImageTests: XCTestCase {
+ func testInitialMirrorsAvatarView() {
+ XCTAssertEqual(MonogramImage.initial(for: "paul"), "P")
+ XCTAssertEqual(MonogramImage.initial(for: " Éowyn "), "É")
+ XCTAssertEqual(MonogramImage.initial(for: " "), "?")
+ XCTAssertEqual(MonogramImage.initial(for: "🦊 Fox"), "🦊")
+ }
+
+ func testPaletteIndexIsDeterministicDjb2() {
+ // djb2("Eleven") computed by the Avatar view's exact loop.
+ var h = 5381
+ for b in "Eleven".utf8 { h = (h &* 33) &+ Int(b) }
+ XCTAssertEqual(MonogramImage.paletteIndex(for: "Eleven"), abs(h) % 8)
+ XCTAssertEqual(MonogramImage.paletteIndex(for: "Eleven"),
+ MonogramImage.paletteIndex(for: "Eleven"))
+ XCTAssertTrue((0..<8).contains(MonogramImage.paletteIndex(for: "任意の名前")))
+ }
+
+ func testRendersNonEmptyPNG() throws {
+ let data = try XCTUnwrap(MonogramImage.pngData(name: "Paul"))
+ // PNG magic — a real encode, not junk bytes.
+ XCTAssertEqual(Array(data.prefix(4)), [0x89, 0x50, 0x4E, 0x47])
+ XCTAssertGreaterThan(data.count, 500)
+ }
+}