From 9f6910ba7328029157f37c9e7f9f594820199bf9 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Thu, 11 Apr 2024 12:44:41 -0400 Subject: [PATCH] Implement communication notifications --- .../NotificationService.swift | 204 +++++++++++++++++- .../Pachyderm/Sources/Pachyderm/Client.swift | 4 + .../Sources/Pachyderm/Model/Status.swift | 12 ++ Tusker.xcodeproj/project.pbxproj | 18 +- Tusker/HTMLConverter.swift | 1 + Tusker/Info.plist | 3 +- Tusker/en.lproj/Localizable.stringsdict | 2 + 7 files changed, 237 insertions(+), 7 deletions(-) diff --git a/NotificationExtension/NotificationService.swift b/NotificationExtension/NotificationService.swift index 2657316c..c17edaf5 100644 --- a/NotificationExtension/NotificationService.swift +++ b/NotificationExtension/NotificationService.swift @@ -12,13 +12,21 @@ import PushNotifications import CryptoKit import OSLog import Pachyderm +import Intents +import HTMLStreamer +import WebURL private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NotificationService") class NotificationService: UNNotificationServiceExtension { + + private static let textConverter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLCallbacks.self) + + private var pendingRequest: (UNMutableNotificationContent, (UNNotificationContent) -> Void, Task)? override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { guard let mutableContent = request.content.mutableCopy() as? UNMutableNotificationContent else { + logger.error("Couldn't get mutable content") contentHandler(request.content) return } @@ -54,10 +62,172 @@ class NotificationService: UNNotificationServiceExtension { mutableContent.title = notification.title mutableContent.body = notification.body - contentHandler(mutableContent) + let task = Task { + await updateNotificationContent(mutableContent, account: account, push: notification) + if !Task.isCancelled { + contentHandler(pendingRequest?.0 ?? mutableContent) + pendingRequest = nil + } + } + pendingRequest = (mutableContent, contentHandler, task) } override func serviceExtensionTimeWillExpire() { + if let pendingRequest { + logger.debug("Expiring with pending request") + pendingRequest.2.cancel() + pendingRequest.1(pendingRequest.0) + self.pendingRequest = nil + } else { + logger.debug("Expiring without pending request") + } + } + + private func updateNotificationContent(_ content: UNMutableNotificationContent, account: UserAccountInfo, push: PushNotification) async { + let client = Client(baseURL: account.instanceURL, accessToken: account.accessToken) + let notification: Pachyderm.Notification + do { + notification = try await client.run(Client.getNotification(id: push.notificationID)).0 + } catch { + logger.error("Error fetching notification: \(String(describing: error))") + return + } + + let kindStr: String? + switch notification.kind { + case .reblog: + kindStr = "🔁 Reblogged" + case .favourite: + kindStr = "⭐️ Favorited" + case .follow: + kindStr = "👤 Followed by @\(notification.account.acct)" + case .followRequest: + kindStr = "👤 Asked to follow by @\(notification.account.acct)" + case .poll: + kindStr = "📊 Poll finished" + case .update: + kindStr = "✏️ Edited" + default: + kindStr = nil + } + + let notificationContent: String? + if let status = notification.status { + notificationContent = NotificationService.textConverter.convert(html: status.content) + } else if notification.kind == .follow || notification.kind == .followRequest { + notificationContent = nil + } else { + notificationContent = push.body + } + + content.body = [kindStr, notificationContent].compactMap { $0 }.joined(separator: "\n") + + let attachmentDataTask: Task? + // We deliberately don't include attachments for other types of notifications that have statuses (favs, etc.) + // because we risk just fetching the same thing a bunch of times for many senders. + if notification.kind == .mention || notification.kind == .status || notification.kind == .update, + let attachment = notification.status?.attachments.first { + let url = attachment.previewURL ?? attachment.url + attachmentDataTask = Task { + do { + let data = try await URLSession.shared.data(from: url).0 + let localAttachmentURL = FileManager.default.temporaryDirectory.appendingPathComponent("attachment_\(attachment.id)").appendingPathExtension(url.pathExtension) + try data.write(to: localAttachmentURL) + return localAttachmentURL + } catch { + logger.error("Error setting notification attachments: \(String(describing: error))") + return nil + } + } + } else { + attachmentDataTask = nil + } + + let conversationIdentifier: String? + if let status = notification.status { + if let context = status.pleromaExtras?.context { + conversationIdentifier = "context:\(context)" + } else if [Notification.Kind.reblog, .favourite, .poll, .update].contains(notification.kind) { + conversationIdentifier = "status:\(status.id)" + } else { + conversationIdentifier = nil + } + } else { + conversationIdentifier = nil + } + + let account: Account? + switch notification.kind { + case .mention, .status: + account = notification.status?.account + default: + account = notification.account + } + let sender: INPerson? + if let account { + let handle = INPersonHandle(value: "@\(account.acct)", type: .unknown) + let image: INImage? + if let avatar = account.avatar, + let (data, resp) = try? await URLSession.shared.data(from: avatar), + let code = (resp as? HTTPURLResponse)?.statusCode, + (200...299).contains(code) { + image = INImage(imageData: data) + } else { + image = nil + } + sender = INPerson( + personHandle: handle, + nameComponents: nil, + displayName: account.displayName, + image: image, + contactIdentifier: nil, + customIdentifier: account.id + ) + } else { + sender = nil + } + + let intent = INSendMessageIntent( + recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: notificationContent, + speakableGroupName: nil, + conversationIdentifier: conversationIdentifier, + serviceName: nil, + sender: sender, + attachments: nil + ) + + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + + do { + try await interaction.donate() + } catch { + logger.error("Error donating interaction: \(String(describing: error))") + return + } + + let updatedContent: UNMutableNotificationContent + do { + let newContent = try content.updating(from: intent) + if let newMutableContent = newContent.mutableCopy() as? UNMutableNotificationContent { + pendingRequest?.0 = newMutableContent + updatedContent = newMutableContent + } else { + updatedContent = content + } + } catch { + logger.error("Error updating notification from intent: \(String(describing: error))") + updatedContent = content + } + + if let localAttachmentURL = await attachmentDataTask?.value, + let attachment = try? UNNotificationAttachment(identifier: localAttachmentURL.lastPathComponent, url: localAttachmentURL) { + updatedContent.attachments = [ + attachment + ] + } } private func getSubscription(account: UserAccountInfo) -> PushNotifications.PushSubscription? { @@ -148,3 +318,35 @@ private func decodeBase64URL(_ s: String) -> Data? { } return Data(base64Encoded: str) } + +// copied from HTMLConverter.Callbacks, blergh +private struct HTMLCallbacks: HTMLConversionCallbacks { + static func makeURL(string: String) -> URL? { + // Converting WebURL to URL is a small but non-trivial expense (since it works by + // serializing the WebURL as a string and then having Foundation parse it again), + // so, if available, use the system parser which doesn't require another round trip. + if #available(iOS 16.0, macOS 13.0, *), + let url = try? URL.ParseStrategy().parse(string) { + url + } else if let web = WebURL(string), + let url = URL(web) { + url + } else { + URL(string: string) + } + } + + static func elementAction(name: String, attributes: [Attribute]) -> ElementAction { + guard name == "span" else { + return .default + } + let clazz = attributes.attributeValue(for: "class") + if clazz == "invisible" { + return .skip + } else if clazz == "ellipsis" { + return .append("…") + } else { + return .default + } + } +} diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift index de1e0874..429d7c2b 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Client.swift @@ -341,6 +341,10 @@ public struct Client: Sendable { } // MARK: - Notifications + public static func getNotification(id: String) -> Request { + return Request(method: .get, path: "/api/v1/notifications/\(id)") + } + public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> { var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters: "types" => allowedTypes.map { $0.rawValue } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift index f4c60bbf..da96d644 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Status.swift @@ -46,6 +46,8 @@ public final class Status: StatusProtocol, Decodable, Sendable { public let localOnly: Bool? public let editedAt: Date? + public let pleromaExtras: PleromaExtras? + public var applicationName: String? { application?.name } public init(from decoder: Decoder) throws { @@ -98,6 +100,8 @@ public final class Status: StatusProtocol, Decodable, Sendable { self.card = try container.decodeIfPresent(Card.self, forKey: .card) self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll) self.editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt) + + self.pleromaExtras = try container.decodeIfPresent(PleromaExtras.self, forKey: .pleromaExtras) } public static func getContext(_ statusID: String) -> Request { @@ -212,7 +216,15 @@ public final class Status: StatusProtocol, Decodable, Sendable { case poll case localOnly = "local_only" case editedAt = "edited_at" + + case pleromaExtras = "pleroma" } } extension Status: Identifiable {} + +extension Status { + public struct PleromaExtras: Decodable, Sendable { + public let context: String? + } +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 829c528e..9ee95655 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -100,7 +100,8 @@ D630C3DF2BC61C4900208903 /* PushNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3DE2BC61C4900208903 /* PushNotifications */; }; D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E02BC61C6700208903 /* UserAccounts */; }; D630C3E52BC6313400208903 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E42BC6313400208903 /* Pachyderm */; }; - D630C3E72BC6313F00208903 /* WebURLFoundationExtras in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E62BC6313F00208903 /* WebURLFoundationExtras */; }; + D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */ = {isa = PBXBuildFile; productRef = D630C4222BC7842C00208903 /* HTMLStreamer */; }; + D630C4252BC7845800208903 /* WebURL in Frameworks */ = {isa = PBXBuildFile; productRef = D630C4242BC7845800208903 /* WebURL */; }; D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6311C4F25B3765B00B27539 /* ImageDataCache.swift */; }; D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B362137838300CE884A /* AttributedString+Helpers.swift */; }; D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; }; @@ -800,8 +801,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D630C4252BC7845800208903 /* WebURL in Frameworks */, + D630C4232BC7842C00208903 /* HTMLStreamer in Frameworks */, D630C3E52BC6313400208903 /* Pachyderm in Frameworks */, - D630C3E72BC6313F00208903 /* WebURLFoundationExtras in Frameworks */, D630C3DF2BC61C4900208903 /* PushNotifications in Frameworks */, D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */, ); @@ -1736,7 +1738,8 @@ D630C3DE2BC61C4900208903 /* PushNotifications */, D630C3E02BC61C6700208903 /* UserAccounts */, D630C3E42BC6313400208903 /* Pachyderm */, - D630C3E62BC6313F00208903 /* WebURLFoundationExtras */, + D630C4222BC7842C00208903 /* HTMLStreamer */, + D630C4242BC7845800208903 /* WebURL */, ); productName = NotificationExtension; productReference = D630C3D12BC61B6000208903 /* NotificationExtension.appex */; @@ -3250,10 +3253,15 @@ isa = XCSwiftPackageProductDependency; productName = Pachyderm; }; - D630C3E62BC6313F00208903 /* WebURLFoundationExtras */ = { + D630C4222BC7842C00208903 /* HTMLStreamer */ = { + isa = XCSwiftPackageProductDependency; + package = D60BB3922B30076F00DAEA65 /* XCRemoteSwiftPackageReference "HTMLStreamer" */; + productName = HTMLStreamer; + }; + D630C4242BC7845800208903 /* WebURL */ = { isa = XCSwiftPackageProductDependency; package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */; - productName = WebURLFoundationExtras; + productName = WebURL; }; D635237029B78A7D009ED5E7 /* TuskerComponents */ = { isa = XCSwiftPackageProductDependency; diff --git a/Tusker/HTMLConverter.swift b/Tusker/HTMLConverter.swift index bbcf72b4..8dcad658 100644 --- a/Tusker/HTMLConverter.swift +++ b/Tusker/HTMLConverter.swift @@ -36,6 +36,7 @@ class HTMLConverter { } extension HTMLConverter { + // note: this is duplicated in NotificationExtension struct Callbacks: HTMLConversionCallbacks { static func makeURL(string: String) -> URL? { // Converting WebURL to URL is a small but non-trivial expense (since it works by diff --git a/Tusker/Info.plist b/Tusker/Info.plist index 587a8344..5e2230e5 100644 --- a/Tusker/Info.plist +++ b/Tusker/Info.plist @@ -56,11 +56,12 @@ NSMicrophoneUsageDescription Post videos from the camera. NSPhotoLibraryAddUsageDescription - Save photos directly from other people's posts. + Save photos directly from other people's posts. NSPhotoLibraryUsageDescription Post photos from the photo library. NSUserActivityTypes + INSendMessageIntent $(PRODUCT_BUNDLE_IDENTIFIER).activity.show-conversation $(PRODUCT_BUNDLE_IDENTIFIER).activity.show-timeline $(PRODUCT_BUNDLE_IDENTIFIER).activity.check-notifications diff --git a/Tusker/en.lproj/Localizable.stringsdict b/Tusker/en.lproj/Localizable.stringsdict index f3f3e244..8a094819 100644 --- a/Tusker/en.lproj/Localizable.stringsdict +++ b/Tusker/en.lproj/Localizable.stringsdict @@ -2,6 +2,8 @@ + apns_enc + Encrypted Notification poll votes count NSStringLocalizedFormatKey