272 lines
9.5 KiB
Swift
272 lines
9.5 KiB
Swift
import Intents
|
|
import UIKit
|
|
import UserNotifications
|
|
|
|
let APP_GROUP = "group.app.bsky"
|
|
typealias ContentHandler = (UNNotificationContent) -> Void
|
|
|
|
// This extension allows us to do some processing of the received notification
|
|
// data before displaying the notification to the user. In our use case, there
|
|
// are a few particular things that we want to do:
|
|
//
|
|
// - Determine whether we should play a sound for the notification
|
|
// - Download and display any images for the notification
|
|
// - Update the badge count accordingly
|
|
//
|
|
// The extension may or may not create a new process to handle a notification.
|
|
// It is also possible that multiple notifications will be processed by the
|
|
// same instance of `NotificationService`, though these will happen in
|
|
// parallel.
|
|
//
|
|
// Because multiple instances of `NotificationService` may exist, we should
|
|
// be careful in accessing preferences that will be mutated _by the
|
|
// extension itself_. For example, we should not worry about `playChatSound`
|
|
// changing, since we never mutate that value within the extension itself.
|
|
// However, since we mutate `badgeCount` frequently, we should ensure that
|
|
// these updates always run sync with each other and that the have access
|
|
// to the most recent values.
|
|
|
|
class NotificationService: UNNotificationServiceExtension {
|
|
private var contentHandler: ContentHandler?
|
|
private var bestAttempt: UNMutableNotificationContent?
|
|
|
|
override func didReceive(
|
|
_ request: UNNotificationRequest,
|
|
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
|
) {
|
|
self.contentHandler = contentHandler
|
|
|
|
guard let bestAttempt = NSEUtil.createCopy(request.content),
|
|
let reason = request.content.userInfo["reason"] as? String
|
|
else {
|
|
contentHandler(request.content)
|
|
return
|
|
}
|
|
|
|
self.bestAttempt = bestAttempt
|
|
|
|
if reason == "chat-message" || reason == "chat-reaction" {
|
|
mutateWithChatMessage(bestAttempt)
|
|
// Only apply the title->body swap for chat-message. For chat-reaction,
|
|
// `messageKind` refers to the reacted-to message, so we'd clobber the
|
|
// descriptive reaction body with the title for reactions on system
|
|
// messages.
|
|
if reason == "chat-message" {
|
|
mutateChatMessageBody(bestAttempt, userInfo: request.content.userInfo)
|
|
}
|
|
mutateWithGroupSubtitle(bestAttempt, userInfo: request.content.userInfo)
|
|
let finalContent = createCommunicationNotification(
|
|
from: bestAttempt,
|
|
userInfo: request.content.userInfo
|
|
)
|
|
contentHandler(finalContent)
|
|
} else if reason == "chat-added-to-group"
|
|
|| reason == "chat-removed-from-group"
|
|
|| reason == "chat-join-request-rejected" {
|
|
mutateWithChatMessage(bestAttempt)
|
|
mutateWithGroupSubtitle(bestAttempt, userInfo: request.content.userInfo)
|
|
mutateWithBadge(bestAttempt)
|
|
contentHandler(bestAttempt)
|
|
} else {
|
|
mutateWithBadge(bestAttempt)
|
|
contentHandler(bestAttempt)
|
|
}
|
|
}
|
|
|
|
override func serviceExtensionTimeWillExpire() {
|
|
guard let contentHandler = self.contentHandler,
|
|
let bestAttempt = self.bestAttempt
|
|
else {
|
|
return
|
|
}
|
|
contentHandler(bestAttempt)
|
|
}
|
|
|
|
// MARK: Communication Notification
|
|
|
|
func createCommunicationNotification(
|
|
from content: UNMutableNotificationContent,
|
|
userInfo: [AnyHashable: Any]
|
|
) -> UNNotificationContent {
|
|
let senderDisplayName = userInfo["senderDisplayName"] as? String ?? "Unknown"
|
|
let convoId = userInfo["convoId"] as? String
|
|
var avatarImage: INImage?
|
|
if let avatarUrlString = userInfo["senderAvatarUrl"] as? String {
|
|
avatarImage = downloadAvatarImage(from: avatarUrlString)
|
|
}
|
|
|
|
let senderHandleValue = userInfo["senderHandle"] as? String
|
|
let senderHandle = INPersonHandle(value: senderHandleValue, type: .unknown)
|
|
let sender = INPerson(
|
|
personHandle: senderHandle,
|
|
nameComponents: nil,
|
|
displayName: senderDisplayName,
|
|
image: avatarImage,
|
|
contactIdentifier: nil,
|
|
customIdentifier: nil
|
|
)
|
|
|
|
var speakableGroupName: INSpeakableString?
|
|
if userInfo["convoKind"] as? String == "group",
|
|
let groupName = userInfo["groupName"] as? String,
|
|
!groupName.isEmpty {
|
|
speakableGroupName = INSpeakableString(spokenPhrase: groupName)
|
|
}
|
|
|
|
let intent = INSendMessageIntent(
|
|
recipients: nil,
|
|
outgoingMessageType: .outgoingMessageText,
|
|
content: content.body,
|
|
speakableGroupName: speakableGroupName,
|
|
conversationIdentifier: convoId,
|
|
serviceName: nil,
|
|
sender: sender,
|
|
attachments: nil
|
|
)
|
|
|
|
// The push payload omits the full recipient list for group convos. Without
|
|
// any signal of multiple recipients, iOS classifies the notification as
|
|
// `MessagingDirect` and ignores `speakableGroupName`. Setting
|
|
// `recipientCount` on the donation metadata is Apple's documented escape
|
|
// hatch for this case, and overrides the (zero-length) recipients array.
|
|
if userInfo["convoKind"] as? String == "group",
|
|
let groupMemberCount = userInfo["groupMemberCount"] as? Int,
|
|
groupMemberCount > 0 {
|
|
let metadata = INSendMessageIntentDonationMetadata()
|
|
metadata.recipientCount = groupMemberCount
|
|
intent.donationMetadata = metadata
|
|
}
|
|
|
|
// For group convos, attach the composite group avatar (rendered by the
|
|
// ogcard service) to the `speakableGroupName` parameter so iOS shows it
|
|
// alongside the sender on the Communication Notification.
|
|
// Disabled: the composite avatar renders poorly at notification size.
|
|
// if userInfo["convoKind"] as? String == "group",
|
|
// let convoAvatarUrlString = userInfo["convoAvatarUrl"] as? String,
|
|
// let groupImage = downloadAvatarImage(from: convoAvatarUrlString) {
|
|
// intent.setImage(groupImage, forParameterNamed: \.speakableGroupName)
|
|
// }
|
|
|
|
// Set the group image to the sender avatar instead
|
|
if userInfo["convoKind"] as? String == "group",
|
|
let avatarImage {
|
|
intent.setImage(avatarImage, forParameterNamed: \.speakableGroupName)
|
|
}
|
|
|
|
let interaction = INInteraction(intent: intent, response: nil)
|
|
interaction.direction = .incoming
|
|
interaction.donate(completion: nil)
|
|
|
|
do {
|
|
return try content.updating(from: intent)
|
|
} catch {
|
|
return content
|
|
}
|
|
}
|
|
|
|
func downloadAvatarImage(from urlString: String) -> INImage? {
|
|
let thumbnailUrlString = urlString.replacingOccurrences(
|
|
of: "/img/avatar/",
|
|
with: "/img/avatar_thumbnail/"
|
|
)
|
|
|
|
guard let url = URL(string: thumbnailUrlString) else { return nil }
|
|
|
|
var request = URLRequest(url: url)
|
|
request.timeoutInterval = 5
|
|
|
|
var imageData: Data?
|
|
let semaphore = DispatchSemaphore(value: 0)
|
|
|
|
let task = URLSession.shared.dataTask(with: request) { data, response, _ in
|
|
if let data = data,
|
|
let httpResponse = response as? HTTPURLResponse,
|
|
httpResponse.statusCode == 200 {
|
|
imageData = data
|
|
}
|
|
semaphore.signal()
|
|
}
|
|
task.resume()
|
|
semaphore.wait()
|
|
|
|
guard let data = imageData else { return nil }
|
|
return INImage(imageData: data)
|
|
}
|
|
|
|
// MARK: Mutations
|
|
|
|
func mutateWithBadge(_ content: UNMutableNotificationContent) {
|
|
NSEUtil.shared.prefsQueue.sync {
|
|
var count = NSEUtil.shared.prefs?.integer(forKey: "badgeCount") ?? 0
|
|
count += 1
|
|
|
|
// Set the new badge number for the notification, then store that value for using later
|
|
content.badge = NSNumber(value: count)
|
|
NSEUtil.shared.prefs?.setValue(count, forKey: "badgeCount")
|
|
}
|
|
}
|
|
|
|
func mutateWithChatMessage(_ content: UNMutableNotificationContent) {
|
|
if NSEUtil.shared.prefs?.bool(forKey: "playSoundChat") == true {
|
|
mutateWithDmSound(content)
|
|
}
|
|
}
|
|
|
|
// For group convos, surface the group name as the notification subtitle.
|
|
// The sender's display name is shown as the title (overridden by
|
|
// `INSendMessageIntent` for chat-message/chat-reaction).
|
|
func mutateWithGroupSubtitle(
|
|
_ content: UNMutableNotificationContent,
|
|
userInfo: [AnyHashable: Any]
|
|
) {
|
|
guard userInfo["convoKind"] as? String == "group",
|
|
let groupName = userInfo["groupName"] as? String,
|
|
!groupName.isEmpty
|
|
else {
|
|
return
|
|
}
|
|
content.subtitle = groupName
|
|
}
|
|
|
|
// System messages (`add_member`, `convo_locked`, `edit_group`, etc.) are
|
|
// delivered through `chat-message` but have a server-rendered description
|
|
// in `title`. iOS overrides the title with the sender name once we apply
|
|
// `INSendMessageIntent`, so we move the title text into the body before
|
|
// building the intent.
|
|
func mutateChatMessageBody(
|
|
_ content: UNMutableNotificationContent,
|
|
userInfo: [AnyHashable: Any]
|
|
) {
|
|
guard let messageKind = userInfo["messageKind"] as? String,
|
|
messageKind != "message",
|
|
!content.title.isEmpty
|
|
else {
|
|
return
|
|
}
|
|
content.body = content.title
|
|
}
|
|
|
|
func mutateWithDefaultSound(_ content: UNMutableNotificationContent) {
|
|
content.sound = UNNotificationSound.default
|
|
}
|
|
|
|
func mutateWithDmSound(_ content: UNMutableNotificationContent) {
|
|
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "dm.aiff"))
|
|
}
|
|
}
|
|
|
|
// NSEUtil's purpose is to create a shared instance of `UserDefaults` across
|
|
// `NotificationService` instances. It also includes a queue so that we can process
|
|
// updates to `UserDefaults` in parallel.
|
|
|
|
private class NSEUtil {
|
|
static let shared = NSEUtil()
|
|
|
|
var prefs = UserDefaults(suiteName: APP_GROUP)
|
|
var prefsQueue = DispatchQueue(label: "NSEPrefsQueue")
|
|
|
|
static func createCopy(_ content: UNNotificationContent) -> UNMutableNotificationContent? {
|
|
return content.mutableCopy() as? UNMutableNotificationContent
|
|
}
|
|
}
|