eleven / ocho Public

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>
Paul Campbell 95591b4d6deff0417125b94173f550412d66113c parent 8c94bba
13 files changed, +295 −28
  • ios/LChat/AvatarLoader.swift +1 −12
  • ios/LChat/Info.plist +4 −0
  • ios/LChat/LChat-Debug.entitlements +4 −0
  • ios/LChat/LChat.entitlements +4 −0
  • ios/LChatCore/Sources/LChatCore/BlobImageLoader.swift +1 −12
  • ios/LChatCore/Sources/LChatCore/CommunicationNotification.swift +57 −0
  • ios/LChatCore/Sources/LChatCore/Models.swift +10 −0
  • ios/LChatCore/Sources/LChatCore/SharedAvatarCache.swift +98 −0
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +9 −0
  • ios/LChatCore/Tests/LChatCoreTests/SharedAvatarCacheTests.swift +68 −0
  • ios/LChatMac/NativeChatView.swift +14 −1
  • ios/LChatNSE/NotificationService.swift +20 −3
  • ios/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: