diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj
index 9b37c77..40be252 100644
--- a/ios/App/App.xcodeproj/project.pbxproj
+++ b/ios/App/App.xcodeproj/project.pbxproj
@@ -467,7 +467,7 @@
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.6;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
- PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum;
+ PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forumtest;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
@@ -489,7 +489,7 @@
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.6;
- PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum;
+ PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forumtest;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
@@ -521,7 +521,7 @@
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
- PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum.NotificationService;
+ PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forumtest.nse;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
@@ -554,7 +554,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
- PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum.NotificationService;
+ PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forumtest.nse;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
diff --git a/ios/App/App/App.entitlements b/ios/App/App/App.entitlements
index 903def2..29326e3 100644
--- a/ios/App/App/App.entitlements
+++ b/ios/App/App/App.entitlements
@@ -4,5 +4,7 @@
aps-environment
development
+ com.apple.developer.usernotifications.communication
+
diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist
index 8e8d560..768a855 100644
--- a/ios/App/App/Info.plist
+++ b/ios/App/App/Info.plist
@@ -35,6 +35,10 @@
Used to find forums and communities near you
NSPhotoLibraryUsageDescription
Access your photos to set a profile picture and share with others
+ NSUserActivityTypes
+
+ INSendMessageIntent
+
UIBackgroundModes
remote-notification
diff --git a/ios/App/NotificationService/COMMUNICATION_NOTIFICATIONS.md b/ios/App/NotificationService/COMMUNICATION_NOTIFICATIONS.md
new file mode 100644
index 0000000..1ad3435
--- /dev/null
+++ b/ios/App/NotificationService/COMMUNICATION_NOTIFICATIONS.md
@@ -0,0 +1,53 @@
+# Communication Notifications (avatar + app-icon badge)
+
+The Notification Service Extension (`NotificationService.swift`) turns incoming
+pushes into **communication notifications** on iOS 15+: a large circular avatar
+(the sending network's photo) with the app icon badged in the corner.
+
+## How it works
+
+1. The server (`frm.so`, `notifications.js`) sets `mutableContent: 1` and adds to
+ the push payload:
+ - `imageUrl` — a PNG of the network's logo (the server rasterizes SVG logos to
+ PNG via `sharp`, since iOS can't render SVG avatars).
+ - `senderName` — the network name (used as the conversation title; falls back
+ to the notification's own title).
+ - `isGroup` — `true`, so iOS uses the group-avatar style.
+2. The extension downloads `imageUrl`, builds an `INSendMessageIntent` with the
+ image as the sender/group avatar, donates it, and returns
+ `content.updating(from: intent)`.
+3. On iOS 14 the extension falls back to attaching the image as a plain thumbnail.
+
+## Required setup (both pieces are mandatory)
+
+Two things must be in place or the avatar silently won't render:
+
+### 1. `NSUserActivityTypes` declares the intent (already in this repo)
+
+`INSendMessageIntent` is declared under `NSUserActivityTypes` in **both**
+`App/Info.plist` and `NotificationService/Info.plist`. Without this the donation
+fails with *"Donating intent 'INSendMessageIntent' is not supported by this
+extension"* and the communication styling never applies.
+
+### 2. Communication Notifications capability (one-time, in Xcode — cannot be scripted)
+
+The capability must be registered on the provisioning profile, not just present
+in the entitlements file:
+
+1. Open `ios/App/App.xcworkspace` in Xcode.
+2. Select the **App** target → **Signing & Capabilities** → **+ Capability** →
+ add **Communication Notifications**.
+ - This adds `com.apple.developer.usernotifications.communication` to
+ `App.entitlements` (already in this repo) and, crucially, enables the
+ capability on the App ID / provisioning profile. Requires a paid Apple
+ Developer account.
+3. If the avatar still doesn't appear, add the same capability to the
+ **NotificationService** target as well.
+4. Rebuild and reinstall on a device (communication notifications don't reliably
+ render on the Simulator).
+
+## Notes
+
+- Requires iOS 15.0+ for the communication style; older versions get the plain
+ attachment fallback automatically.
+- The app's deployment target is currently 14.0, so no bump is required.
diff --git a/ios/App/NotificationService/Info.plist b/ios/App/NotificationService/Info.plist
index 57421eb..ad04f44 100644
--- a/ios/App/NotificationService/Info.plist
+++ b/ios/App/NotificationService/Info.plist
@@ -2,6 +2,10 @@
+ NSUserActivityTypes
+
+ INSendMessageIntent
+
NSExtension
NSExtensionPointIdentifier
diff --git a/ios/App/NotificationService/NotificationService.swift b/ios/App/NotificationService/NotificationService.swift
index 37c2967..561405a 100644
--- a/ios/App/NotificationService/NotificationService.swift
+++ b/ios/App/NotificationService/NotificationService.swift
@@ -6,6 +6,7 @@
//
import UserNotifications
+import Intents
class NotificationService: UNNotificationServiceExtension {
@@ -25,21 +26,91 @@ class NotificationService: UNNotificationServiceExtension {
return
}
- URLSession.shared.downloadTask(with: imageUrl) { location, _, error in
- defer { contentHandler(content) }
- guard let location = location, error == nil else { return }
+ let userInfo = request.content.userInfo
- let tmpFile = URL(fileURLWithPath: NSTemporaryDirectory())
- .appendingPathComponent(imageUrl.lastPathComponent)
+ // Download the avatar (the sending network's photo), then render the
+ // notification. A data task lets us hand the bytes straight to INImage.
+ URLSession.shared.dataTask(with: imageUrl) { data, _, error in
+ guard let data = data, !data.isEmpty, error == nil else {
+ contentHandler(content)
+ return
+ }
- try? FileManager.default.moveItem(at: location, to: tmpFile)
-
- if let attachment = try? UNNotificationAttachment(identifier: "avatar", url: tmpFile) {
- content.attachments = [attachment]
+ // iOS 15+: render as a communication notification — large circular
+ // avatar with the app icon badged in the corner.
+ if #available(iOS 15.0, *) {
+ self.deliverAsCommunicationNotification(content: content, imageData: data, userInfo: userInfo, contentHandler: contentHandler)
+ } else {
+ // iOS 14 fallback: attach the image as a plain thumbnail.
+ let tmpFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("avatar.png")
+ try? data.write(to: tmpFile)
+ if let attachment = try? UNNotificationAttachment(identifier: "avatar", url: tmpFile) {
+ content.attachments = [attachment]
+ }
+ contentHandler(content)
}
}.resume()
}
+ @available(iOS 15.0, *)
+ private func deliverAsCommunicationNotification(
+ content: UNMutableNotificationContent,
+ imageData: Data,
+ userInfo: [AnyHashable: Any],
+ contentHandler: @escaping (UNNotificationContent) -> Void
+ ) {
+ // The conversation title shown in bold. Defaults to the notification's own
+ // title so existing copy (e.g. "Group | Alice") is preserved.
+ let senderName = (userInfo["senderName"] as? String) ?? content.title
+ let isGroup = (userInfo["isGroup"] as? Bool) ?? false
+ // Group related notifications into one conversation so iOS stacks them.
+ let conversationId = (userInfo["conversationId"] as? String)
+ ?? (userInfo["threadId"] as? String)
+ ?? content.threadIdentifier
+
+ let avatar = INImage(imageData: imageData)
+
+ let handle = INPersonHandle(value: conversationId, type: .unknown)
+ let sender = INPerson(
+ personHandle: handle,
+ nameComponents: nil,
+ displayName: senderName,
+ image: avatar,
+ contactIdentifier: nil,
+ customIdentifier: conversationId
+ )
+
+ let intent = INSendMessageIntent(
+ recipients: nil,
+ outgoingMessageType: .outgoingMessageText,
+ content: content.body,
+ speakableGroupName: isGroup ? INSpeakableString(spokenPhrase: senderName) : nil,
+ conversationIdentifier: conversationId,
+ serviceName: nil,
+ sender: sender,
+ attachments: nil
+ )
+
+ // For a group conversation the avatar comes from the group image; for a
+ // 1:1 it comes from the sender's INPerson image (set above).
+ if isGroup {
+ intent.setImage(avatar, forParameterNamed: \.speakableGroupName)
+ }
+
+ let interaction = INInteraction(intent: intent, response: nil)
+ interaction.direction = .incoming
+ interaction.donate(completion: nil)
+
+ do {
+ let updated = try content.updating(from: intent)
+ contentHandler(updated)
+ } catch {
+ // If the intent can't be applied, fall back to the unmodified content
+ // so the notification still shows.
+ contentHandler(content)
+ }
+ }
+
override func serviceExtensionTimeWillExpire() {
if let contentHandler = contentHandler, let content = bestAttemptContent {
contentHandler(content)
diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock
index bf7b21b..a1a7e94 100644
--- a/ios/App/Podfile.lock
+++ b/ios/App/Podfile.lock
@@ -1,26 +1,28 @@
PODS:
- - Capacitor (7.4.4):
+ - Capacitor (7.6.6):
- CapacitorCordova
- - CapacitorCamera (7.0.2):
+ - CapacitorCamera (7.0.5):
- Capacitor
- - CapacitorCordova (7.4.4)
+ - CapacitorCordova (7.6.6)
- CapacitorFilesystem (7.1.8):
- Capacitor
- IONFilesystemLib (~> 1.1.1)
- - CapacitorGeolocation (7.1.5):
+ - CapacitorGeolocation (7.1.8):
- Capacitor
- IONGeolocationLib (= 1.0.1)
- - CapacitorGoogleMaps (7.2.0):
+ - CapacitorGoogleMaps (7.2.2):
- Capacitor
- Google-Maps-iOS-Utils (~> 5.0)
- GoogleMaps (~> 8.4)
- - CapacitorHaptics (7.0.3):
+ - CapacitorHaptics (7.0.5):
- Capacitor
- CapacitorPreferences (7.0.4):
- Capacitor
- CapacitorPushNotifications (7.0.6):
- Capacitor
- - CapacitorSplashScreen (7.0.3):
+ - CapacitorSplashScreen (7.0.5):
+ - Capacitor
+ - ForumCapacitorNativeInput (1.0.0):
- Capacitor
- Google-Maps-iOS-Utils (5.0.0):
- GoogleMaps (~> 8.0)
@@ -45,6 +47,7 @@ DEPENDENCIES:
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
- "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)"
+ - ForumCapacitorNativeInput (from `../../native-input`)
- "NotnotsamuelCapacitorSwipeBack (from `../../node_modules/@notnotsamuel/capacitor-swipe-back`)"
SPEC REPOS:
@@ -75,26 +78,29 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/push-notifications"
CapacitorSplashScreen:
:path: "../../node_modules/@capacitor/splash-screen"
+ ForumCapacitorNativeInput:
+ :path: "../../native-input"
NotnotsamuelCapacitorSwipeBack:
:path: "../../node_modules/@notnotsamuel/capacitor-swipe-back"
SPEC CHECKSUMS:
- Capacitor: 09d9ff8e9618e8c4b3cab2bbee34a17215dd2fef
- CapacitorCamera: 6e18d54c8ab30d7dc7b8cd93d96f9b4f57e9202a
- CapacitorCordova: bf648a636f3c153f652d312ae145fb508b6ffced
- CapacitorFilesystem: f54cd6b76be06fa7ceb219cf313d32e0d626ea87
- CapacitorGeolocation: b96474c3259dd4a294227ea8ec19140b1837cceb
- CapacitorGoogleMaps: 20b5445a532f80dbb120fa99941fd094bcc88af6
- CapacitorHaptics: d17da7dd984cae34111b3f097ccd3e21f9feec62
- CapacitorPreferences: d82a7e3b95fcab43a553268b803356522910d153
- CapacitorPushNotifications: c6158ba6f3777f281a675aa43e4011e9723e822b
- CapacitorSplashScreen: d06ae8804808e9f649a08e7bb7f283c77b688084
+ Capacitor: 961efd309ab0e390a8081b63c561186f61a6714d
+ CapacitorCamera: 9e952270be355797f769aa835bb7643a96c871fe
+ CapacitorCordova: 3a4e0027a89a59421c918d19aee9d6ba1c7a3556
+ CapacitorFilesystem: c63fc54df41e5a6761785a7f3c49dc696c22e296
+ CapacitorGeolocation: ab54d708e2fcf99a7bafe0219eaa6f7a932f001a
+ CapacitorGoogleMaps: e3a1c0b501daa84a327b6ee807ee515a7f890403
+ CapacitorHaptics: 1b145fa83c622e5383a3bac04ea81e2514f0b0f5
+ CapacitorPreferences: 69d9991307507aeab8ef8019c10b9babfda0e9ca
+ CapacitorPushNotifications: b8e30637549ce91468b8c4dddbef2657be831f4c
+ CapacitorSplashScreen: 409eeaaeac603d264f209c0fcb718aa22fe1f7bb
+ ForumCapacitorNativeInput: 0f83e3b9755ed05fccc3eac1daaf9bf5ecf10f2b
Google-Maps-iOS-Utils: 66d6de12be1ce6d3742a54661e7a79cb317a9321
GoogleMaps: 8939898920281c649150e0af74aa291c60f2e77d
IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a
IONGeolocationLib: 20f9d0248a0b5264511fb57a37e25dd2badf797a
- NotnotsamuelCapacitorSwipeBack: 07a8985928db83b1d62a1d596246db466e69d149
+ NotnotsamuelCapacitorSwipeBack: b3a00145209b368cdfe21816d7919484c3fe654e
-PODFILE CHECKSUM: d7fbdcfecbb81edc39c9975b338d5d15e186424b
+PODFILE CHECKSUM: 7d58799944232142d58ed433a27a383cd373780d
-COCOAPODS: 1.15.2
+COCOAPODS: 1.16.2