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>
3 files changed,
+116
−0
ios/LChatCore/Sources/LChatCore/CommunicationNotification.swift+4 −0ios/LChatCore/Sources/LChatCore/MonogramImage.swift+81 −0ios/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) |
| + } |
| +} |