ios+mac: sender's photo on message notifications (communication notifications) (#467)
Message banners now render as system communication notifications — the sender's profile photo as the big icon with the app icon badged on its corner, WhatsApp-style — via INSendMessageIntent + updating(from:). The NSE can't fetch or decrypt photos itself (no roster, no instance key, ~30s budget), so ThreadStore now parks every member's decrypted, downscaled photo in a SharedAvatarCache in the App Group container on refresh — the face-companion of SharedThreadKeys. The extension just reads bytes. Groups carry the group title as the intent's speakableGroupName (subtitle line); DMs title on the sender alone; app and system events (no named sender) keep the plain banner, as does any build without the new communication entitlement. Mac parity: the native surface's local banners run through the same CommunicationNotification.decorate with the same graceful fallback (the ad-hoc-signed dev build lacks the entitlement, so it keeps plain banners until the Mac gets real signing). Also folds the two private /api/blob URL builders into AvatarSource.url.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
13 files changed,
+295
−28
ios/LChat/AvatarLoader.swift+1 −12ios/LChat/Info.plist+4 −0ios/LChat/LChat-Debug.entitlements+4 −0ios/LChat/LChat.entitlements+4 −0ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift+1 −12ios/LChatCore/Sources/LChatCore/CommunicationNotification.swift+57 −0ios/LChatCore/Sources/LChatCore/Models.swift+10 −0ios/LChatCore/Sources/LChatCore/SharedAvatarCache.swift+98 −0ios/LChatCore/Sources/LChatCore/ThreadStore.swift+9 −0ios/LChatCore/Tests/LChatCoreTests/SharedAvatarCacheTests.swift+68 −0ios/LChatMac/NativeChatView.swift+14 −1ios/LChatNSE/NotificationService.swift+20 −3ios/project.yml+5 −0
diff --git a/ios/LChat/AvatarLoader.swift b/ios/LChat/AvatarLoader.swift| index 035d7d9..7dcca0a 100644 |
| --- a/ios/LChat/AvatarLoader.swift |
| +++ b/ios/LChat/AvatarLoader.swift |
| @@ -31,7 +31,7 @@ final class AvatarLoader { |
| // Detached so the fetch + AES-GCM open + image decode stay off the main |
| // thread; only the cache bookkeeping below runs on the main actor. |
| let task = Task.detached { () -> UIImage? in |
| - guard let url = Self.blobURL(source), |
| + guard let url = source.url, |
| let (data, resp) = try? await session.data(from: url), |
| let http = resp as? HTTPURLResponse, http.statusCode == 200, |
| let key = try? GroupKey(base64url: source.keyB64), |
| @@ -45,15 +45,4 @@ final class AvatarLoader { |
| if let img { cache[source.blobID] = img } |
| return img |
| } |
| - |
| - // /api/blob/<id>?token=… — blob ids are base64url (URL-safe), so appending |
| - // them as a path component needs no extra escaping. Token-gated like every |
| - // data endpoint, exactly as the web client fetches it. |
| - nonisolated private static func blobURL(_ s: AvatarSource) -> URL? { |
| - var comps = URLComponents( |
| - url: s.baseURL.appending(path: "api/blob").appending(path: s.blobID), |
| - resolvingAgainstBaseURL: false) |
| - comps?.queryItems = [URLQueryItem(name: "token", value: s.token)] |
| - return comps?.url |
| - } |
| } |
diff --git a/ios/LChat/Info.plist b/ios/LChat/Info.plist| index a5e74a0..6163179 100644 |
| --- a/ios/LChat/Info.plist |
| +++ b/ios/LChat/Info.plist |
| @@ -44,6 +44,10 @@ |
| </dict> |
| <key>NSCameraUsageDescription</key> |
| <string>Camera access is needed to scan QR codes for joining chats</string> |
| + <key>NSUserActivityTypes</key> |
| + <array> |
| + <string>INSendMessageIntent</string> |
| + </array> |
| <key>UIBackgroundModes</key> |
| <array> |
| <string>fetch</string> |
diff --git a/ios/LChat/LChat-Debug.entitlements b/ios/LChat/LChat-Debug.entitlements| index 7a5550a..262a358 100644 |
| --- a/ios/LChat/LChat-Debug.entitlements |
| +++ b/ios/LChat/LChat-Debug.entitlements |
| @@ -10,6 +10,10 @@ |
| <true/> |
| <key>aps-environment</key> |
| <string>development</string> |
| + <!-- Communication notifications: lets the NSE restyle a message banner via |
| + INSendMessageIntent — sender's photo as the icon, app icon badged. --> |
| + <key>com.apple.developer.usernotifications.communication</key> |
| + <true/> |
| <!-- Universal links: tapping an https invite link on any instance subdomain |
| opens the app directly (delivered to onOpenURL, fragment included). --> |
| <key>com.apple.developer.associated-domains</key> |
diff --git a/ios/LChat/LChat.entitlements b/ios/LChat/LChat.entitlements| index df91c16..add067d 100644 |
| --- a/ios/LChat/LChat.entitlements |
| +++ b/ios/LChat/LChat.entitlements |
| @@ -10,6 +10,10 @@ |
| <true/> |
| <key>aps-environment</key> |
| <string>development</string> |
| + <!-- Communication notifications: lets the NSE restyle a message banner via |
| + INSendMessageIntent — sender's photo as the icon, app icon badged. --> |
| + <key>com.apple.developer.usernotifications.communication</key> |
| + <true/> |
| <!-- Universal links: tapping an https invite link on any instance subdomain |
| opens the app directly (delivered to onOpenURL, fragment included). --> |
| <key>com.apple.developer.associated-domains</key> |
diff --git a/ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift b/ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift| index c23fde0..e7557ab 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift |
| @@ -62,7 +62,7 @@ public final class BlobImageLoader { |
| // Detached so the fetch + AES-GCM open + image decode stay off the main |
| // thread; only the cache bookkeeping below runs on the main actor. |
| let task = Task.detached { () -> PlatformImage? in |
| - guard let url = Self.blobURL(source), |
| + guard let url = source.url, |
| let (data, resp) = try? await session.data(from: url), |
| let http = resp as? HTTPURLResponse, http.statusCode == 200, |
| let key = try? GroupKey(base64url: source.keyB64), |
| @@ -76,15 +76,4 @@ public final class BlobImageLoader { |
| if let img { store(img, for: source.blobID) } |
| return img |
| } |
| - |
| - // /api/blob/<id>?token=… — blob ids are base64url (URL-safe), so appending |
| - // them as a path component needs no extra escaping. Token-gated like every |
| - // data endpoint, exactly as the web client fetches it. |
| - nonisolated private static func blobURL(_ s: AvatarSource) -> URL? { |
| - var comps = URLComponents( |
| - url: s.baseURL.appending(path: "api/blob").appending(path: s.blobID), |
| - resolvingAgainstBaseURL: false) |
| - comps?.queryItems = [URLQueryItem(name: "token", value: s.token)] |
| - return comps?.url |
| - } |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/CommunicationNotification.swift b/ios/LChatCore/Sources/LChatCore/CommunicationNotification.swift| new file mode 100644 |
| index 0000000..4a9ae46 |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/CommunicationNotification.swift |
| @@ -0,0 +1,57 @@ |
| +import Foundation |
| +import Intents |
| +import UserNotifications |
| + |
| +// Re-presents a message banner as a system "communication notification" — the |
| +// sender's face as the big icon with the app icon badged on its corner, like |
| +// Messages/WhatsApp — by describing the message to iOS as an INSendMessageIntent |
| +// and letting UNNotificationContent.updating(from:) restyle the content. Shared |
| +// by the iOS notification extension (APNs path) and the Mac's local banners so |
| +// the two platforms can't drift. Purely cosmetic + best-effort: any failure |
| +// (no communication entitlement on this build, malformed image) returns nil and |
| +// the caller ships the plain banner it already built. |
| +public enum CommunicationNotification { |
| + /// The decorated content, or nil to keep the caller's plain banner. |
| + /// - senderUID keys the avatar lookup (SharedAvatarCache) and gives the |
| + /// system a stable per-person identity; without one the sender still gets |
| + /// named, just with a monogram circle. |
| + /// - groupName (a group chat's title) renders as the subtitle line; pass |
| + /// nil for DMs, where the sender name alone is the title. |
| + /// - messageText is the plain body — no "Sender: " prefix; the intent |
| + /// carries the sender, so a prefixed body would read twice. |
| + public static func decorate(_ content: UNNotificationContent, |
| + senderName: String, senderUID: String?, |
| + origin: String, conversationID: String, |
| + groupName: String?, messageText: String) -> UNNotificationContent? { |
| + var image: INImage? |
| + if let uid = senderUID, let data = SharedAvatarCache.imageData(origin: origin, uid: uid) { |
| + image = INImage(imageData: data) |
| + } |
| + // The handle/customIdentifier scope the person to their instance — |
| + // the same uid on two instances is two different people. |
| + let handle = INPersonHandle(value: "\(origin)|\(senderUID ?? senderName)", type: .unknown) |
| + let sender = INPerson(personHandle: handle, nameComponents: nil, |
| + displayName: senderName, image: image, |
| + contactIdentifier: nil, |
| + customIdentifier: handle.value) |
| + let intent = INSendMessageIntent(recipients: nil, |
| + outgoingMessageType: .outgoingMessageText, |
| + content: messageText, |
| + speakableGroupName: groupName.map { INSpeakableString(spokenPhrase: $0) }, |
| + conversationIdentifier: conversationID, |
| + serviceName: nil, |
| + sender: sender, |
| + attachments: nil) |
| + // The donation is what feeds share-sheet/Siri people suggestions; |
| + // the visual restyle below works without it, so ignore failures. |
| + let interaction = INInteraction(intent: intent, response: nil) |
| + interaction.direction = .incoming |
| + interaction.donate(completion: nil) |
| + |
| + guard let mutable = content.mutableCopy() as? UNMutableNotificationContent else { return nil } |
| + // updating(from:) takes title/subtitle from the intent's sender/group; |
| + // the body stays ours, so strip any sender prefix down to the text. |
| + mutable.body = messageText |
| + return try? mutable.updating(from: intent) |
| + } |
| +} |
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift| index ff8c42f..2a50a12 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Models.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Models.swift |
| @@ -202,6 +202,16 @@ public struct AvatarSource: Equatable { |
| public var keyB64: String |
| public var baseURL: URL |
| public var token: String |
| + |
| + /// The token-gated ciphertext fetch URL — /api/blob/<id>?token=… . Blob ids |
| + /// are base64url (URL-safe), so the path component needs no extra escaping. |
| + public var url: URL? { |
| + var comps = URLComponents( |
| + url: baseURL.appending(path: "api/blob").appending(path: blobID), |
| + resolvingAgainstBaseURL: false) |
| + comps?.queryItems = [URLQueryItem(name: "token", value: token)] |
| + return comps?.url |
| + } |
| } |
| // One row in the native thread sidebar — fully decrypted, ready to render. |
diff --git a/ios/LChatCore/Sources/LChatCore/SharedAvatarCache.swift b/ios/LChatCore/Sources/LChatCore/SharedAvatarCache.swift| new file mode 100644 |
| index 0000000..46a97f3 |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/SharedAvatarCache.swift |
| @@ -0,0 +1,98 @@ |
| +import CryptoKit |
| +import Foundation |
| + |
| +// Decrypted, downscaled profile photos shared with the notification extension: |
| +// the NSE has no member roster, no instance key and a ~30s budget, so it can't |
| +// fetch-and-decrypt a sender's photo itself. Instead the app parks a small |
| +// plaintext thumbnail here (App Group container, on-device, OS-protected — |
| +// exactly like the messages MessageStore files next door) whenever a |
| +// ThreadStore refreshes, and the extension just reads bytes to hand |
| +// INSendMessageIntent a sender image. The companion of SharedThreadKeys: keys |
| +// in the keychain, faces in the group container. |
| +public enum SharedAvatarCache { |
| + // Thumbnails only need to fill the ~55pt notification circle; 256px covers |
| + // 3x displays and keeps every file a few KB. |
| + static let thumbMaxDim = 256.0 |
| + |
| + private static let appGroupID = "group.com.elevenmessenger.messenger" |
| + |
| + /// Test seam: points the cache at a temp directory instead of the group |
| + /// container. |
| + static var directoryOverride: URL? |
| + |
| + static func directory() -> URL { |
| + if let directoryOverride { return directoryOverride } |
| + let fm = FileManager.default |
| + // Same fallback shape as MessageStore: ad-hoc dev signing can't mint |
| + // the group entitlement, so each process keeps a private cache (the |
| + // extension then just shows the initials-free banner it always has). |
| + let base = fm.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) |
| + ?? (fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first |
| + ?? URL(fileURLWithPath: NSTemporaryDirectory())) |
| + .appendingPathComponent("lchat", isDirectory: true) |
| + return base.appendingPathComponent("avatars", isDirectory: true) |
| + } |
| + |
| + // One member = one thumbnail + one sidecar recording which encrypted blob |
| + // it came from (so a refresh only refetches when the photo changed). The |
| + // filename hashes origin|uid — filesystem-safe, and two instances' members |
| + // can never collide. |
| + static func fileStem(origin: String, uid: String) -> String { |
| + let digest = SHA256.hash(data: Data("\(origin)|\(uid)".utf8)) |
| + return digest.map { String(format: "%02x", $0) }.joined().prefix(32).description |
| + } |
| + |
| + private static func thumbURL(_ dir: URL, origin: String, uid: String) -> URL { |
| + dir.appendingPathComponent(fileStem(origin: origin, uid: uid) + ".jpg") |
| + } |
| + private static func sourceURL(_ dir: URL, origin: String, uid: String) -> URL { |
| + dir.appendingPathComponent(fileStem(origin: origin, uid: uid) + ".src") |
| + } |
| + |
| + /// The cached thumbnail's JPEG bytes for a member, or nil — the extension's |
| + /// read side. Never fetches. |
| + public static func imageData(origin: String, uid: String) -> Data? { |
| + try? Data(contentsOf: thumbURL(directory(), origin: origin, uid: uid)) |
| + } |
| + |
| + /// Bring the cache in line with a roster: fetch + decrypt + downscale any |
| + /// member photo that's new or changed, drop entries for members whose photo |
| + /// is gone. Runs off-main (network + AES + JPEG per changed photo); a |
| + /// failed fetch just leaves that member's old thumbnail (or nothing) in |
| + /// place — the banner falls back to a monogram, never an error. |
| + public static func sync(origin: String, avatars: [(uid: String, source: AvatarSource?)]) async { |
| + let fm = FileManager.default |
| + let dir = directory() |
| + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) |
| + for (uid, source) in avatars { |
| + let thumb = thumbURL(dir, origin: origin, uid: uid) |
| + let src = sourceURL(dir, origin: origin, uid: uid) |
| + guard let source else { |
| + try? fm.removeItem(at: thumb) |
| + try? fm.removeItem(at: src) |
| + continue |
| + } |
| + // Unchanged blob + file still present → nothing to do (the |
| + // overwhelmingly common case on every refresh). |
| + if (try? String(contentsOf: src, encoding: .utf8)) == source.blobID, |
| + fm.fileExists(atPath: thumb.path) { continue } |
| + guard let url = source.url, |
| + let (cipher, resp) = try? await URLSession.shared.data(from: url), |
| + (resp as? HTTPURLResponse)?.statusCode == 200, |
| + let key = try? GroupKey(base64url: source.keyB64), |
| + let plain = try? key.decryptBytes(cipher), |
| + let jpeg = processImage(plain, maxDim: thumbMaxDim)?.bytes |
| + else { continue } |
| + store(jpeg: jpeg, blobID: source.blobID, origin: origin, uid: uid) |
| + } |
| + } |
| + |
| + /// Write one thumbnail + its source marker (split out so tests can seed the |
| + /// cache without a network). |
| + static func store(jpeg: Data, blobID: String, origin: String, uid: String) { |
| + let dir = directory() |
| + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) |
| + try? jpeg.write(to: thumbURL(dir, origin: origin, uid: uid), options: .atomic) |
| + try? Data(blobID.utf8).write(to: sourceURL(dir, origin: origin, uid: uid), options: .atomic) |
| + } |
| +} |
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift| index 68d177d..ad0ab81 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift |
| @@ -78,6 +78,15 @@ public final class ThreadStore: ObservableObject { |
| instanceKeyB64 = folded.instanceKeyB64 |
| roster = folded.people |
| rosterLabels = folded.rosterLabels |
| + // Park every member's decrypted profile photo (downscaled) in the |
| + // shared cache — the way the fold parks thread keys in |
| + // SharedThreadKeys — so the notification extension can show a sender's |
| + // face on the banner. Off-main; only changed photos actually fetch. |
| + let origin = instance.origin |
| + let avatars = folded.people.map { ($0.user_id, avatarSource(for: $0)) } |
| + Task.detached(priority: .utility) { |
| + await SharedAvatarCache.sync(origin: origin, avatars: avatars) |
| + } |
| for (tid, mark) in folded.readMarks { |
| serverReadMarks[tid] = max(serverReadMarks[tid] ?? 0, mark) |
| } |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/SharedAvatarCacheTests.swift b/ios/LChatCore/Tests/LChatCoreTests/SharedAvatarCacheTests.swift| new file mode 100644 |
| index 0000000..f223665 |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/SharedAvatarCacheTests.swift |
| @@ -0,0 +1,68 @@ |
| +import XCTest |
| +@testable import LChatCore |
| + |
| +// 🤖 The shared avatar-thumbnail cache the notification extension reads to put |
| +// a sender's face on a banner. Network-free: sync's fetch path is exercised |
| +// only for entries whose source marker is stale, so the tests drive the |
| +// skip/remove/roundtrip contract with seeded files and unreachable URLs. |
| +final class SharedAvatarCacheTests: XCTestCase { |
| + private var dir: URL! |
| + |
| + override func setUp() { |
| + super.setUp() |
| + dir = FileManager.default.temporaryDirectory |
| + .appendingPathComponent("avatar-cache-tests-\(UUID().uuidString)", isDirectory: true) |
| + SharedAvatarCache.directoryOverride = dir |
| + } |
| + |
| + override func tearDown() { |
| + SharedAvatarCache.directoryOverride = nil |
| + try? FileManager.default.removeItem(at: dir) |
| + super.tearDown() |
| + } |
| + |
| + private func source(blobID: String) -> AvatarSource { |
| + // .1 is reserved/unroutable → any fetch attempt fails fast. |
| + AvatarSource(blobID: blobID, keyB64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", |
| + baseURL: URL(string: "http://192.0.2.1")!, token: "t") |
| + } |
| + |
| + func testStoreThenReadRoundtrip() { |
| + let jpeg = Data([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]) |
| + SharedAvatarCache.store(jpeg: jpeg, blobID: "b1", origin: "https://a.example", uid: "u1") |
| + XCTAssertEqual(SharedAvatarCache.imageData(origin: "https://a.example", uid: "u1"), jpeg) |
| + // Same uid on another instance is a different person — different slot. |
| + XCTAssertNil(SharedAvatarCache.imageData(origin: "https://b.example", uid: "u1")) |
| + } |
| + |
| + func testStemIsFilesystemSafeAndDistinct() { |
| + let a = SharedAvatarCache.fileStem(origin: "https://a.example", uid: "u/1") |
| + let b = SharedAvatarCache.fileStem(origin: "https://b.example", uid: "u/1") |
| + XCTAssertNotEqual(a, b) |
| + XCTAssertTrue(a.allSatisfy(\.isHexDigit)) |
| + XCTAssertEqual(a, SharedAvatarCache.fileStem(origin: "https://a.example", uid: "u/1")) |
| + } |
| + |
| + func testSyncKeepsUnchangedEntryWithoutFetching() async { |
| + let jpeg = Data([9, 9, 9]) |
| + SharedAvatarCache.store(jpeg: jpeg, blobID: "b1", origin: "o", uid: "u1") |
| + // Marker matches the stored blob → sync must not touch the file (a |
| + // fetch would fail against the unroutable host and drop it). |
| + await SharedAvatarCache.sync(origin: "o", avatars: [("u1", source(blobID: "b1"))]) |
| + XCTAssertEqual(SharedAvatarCache.imageData(origin: "o", uid: "u1"), jpeg) |
| + } |
| + |
| + func testSyncKeepsStaleThumbnailWhenFetchFails() async { |
| + let jpeg = Data([9, 9, 9]) |
| + SharedAvatarCache.store(jpeg: jpeg, blobID: "old", origin: "o", uid: "u1") |
| + // Photo changed but the refetch fails → the old face beats no face. |
| + await SharedAvatarCache.sync(origin: "o", avatars: [("u1", source(blobID: "new"))]) |
| + XCTAssertEqual(SharedAvatarCache.imageData(origin: "o", uid: "u1"), jpeg) |
| + } |
| + |
| + func testSyncRemovesEntryWhenPhotoCleared() async { |
| + SharedAvatarCache.store(jpeg: Data([1]), blobID: "b1", origin: "o", uid: "u1") |
| + await SharedAvatarCache.sync(origin: "o", avatars: [("u1", nil)]) |
| + XCTAssertNil(SharedAvatarCache.imageData(origin: "o", uid: "u1")) |
| + } |
| +} |
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index b507ae5..fd06cbb 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -1033,6 +1033,7 @@ struct NativeChatView: View { |
| else { return } |
| let myUserId = store.myUserId |
| let instanceID = instance.id |
| + let origin = instance.origin |
| // The AES open runs detached — this fires for every message on the |
| // instance socket while backgrounded; posting the banner is |
| // thread-safe. |
| @@ -1046,8 +1047,20 @@ struct NativeChatView: View { |
| content.body = previewText(kind: thread.kind, payload: p) |
| 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, |
| + 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)) { |
| + deliver = styled |
| + } |
| UNUserNotificationCenter.current().add( |
| - UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)) |
| + UNNotificationRequest(identifier: UUID().uuidString, content: deliver, trigger: nil)) |
| } |
| } |
diff --git a/ios/LChatNSE/NotificationService.swift b/ios/LChatNSE/NotificationService.swift| index 6c678e2..9ce76a9 100644 |
| --- a/ios/LChatNSE/NotificationService.swift |
| +++ b/ios/LChatNSE/NotificationService.swift |
| @@ -16,7 +16,7 @@ import UserNotifications |
| // banner itself: a store failure never blocks the notification. |
| final class NotificationService: UNNotificationServiceExtension { |
| private var handler: ((UNNotificationContent) -> Void)? |
| - private var bestEffort: UNMutableNotificationContent? |
| + private var bestEffort: UNNotificationContent? |
| // One-shot: the store-ingest Task and the expiry callback can race; the |
| // banner must ship exactly once. |
| @@ -58,6 +58,22 @@ final class NotificationService: UNNotificationServiceExtension { |
| content.body = entry.group ? "\(sender): \(text)" : text |
| content.threadIdentifier = thread |
| + // 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). |
| + var deliver: UNNotificationContent = content |
| + if let name = payload.name, !name.isEmpty, |
| + let styled = CommunicationNotification.decorate( |
| + content, senderName: name, senderUID: payload.uid, |
| + origin: entry.origin, conversationID: thread, |
| + groupName: entry.group ? entry.title : nil, messageText: text) { |
| + deliver = styled |
| + bestEffort = styled |
| + } |
| + |
| // Ingest into the shared store when the payload carries the message's |
| // server id (older servers/relays don't — the banner still works, the |
| // app just fetches on open as before). Stored via the same projection |
| @@ -66,14 +82,15 @@ final class NotificationService: UNNotificationServiceExtension { |
| let at = (info["at"] as? NSNumber)?.int64Value, |
| let stored = ChatFeedModel.renderable(blob, key: key) |
| else { |
| - ship(content) |
| + ship(deliver) |
| return |
| } |
| + let final = deliver |
| Task { |
| await MessageStore.shared.upsert( |
| instance: entry.origin, thread: thread, |
| [.init(id: id, at: at, edited: false, reactions: [], payload: stored)]) |
| - ship(content) |
| + ship(final) |
| } |
| } |
diff --git a/ios/project.yml b/ios/project.yml| index 1fbd114..2f74be0 100644 |
| --- a/ios/project.yml |
| +++ b/ios/project.yml |
| @@ -69,6 +69,11 @@ targets: |
| CFBundleDisplayName: Eleven |
| NSCameraUsageDescription: Camera access is needed to scan QR codes for joining chats |
| UILaunchScreen: {} |
| + # Communication notifications (sender's photo on message banners): the |
| + # system requires the intent to be declared here, alongside the |
| + # com.apple.developer.usernotifications.communication entitlement. |
| + NSUserActivityTypes: |
| + - INSendMessageIntent |
| # The welcome page's "Open the app" button fires |
| # elevenmessenger://add?url=<invite link> (see web/pages/join.html). |
| CFBundleURLTypes: |