mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
222 lines
7.0 KiB
Swift
222 lines
7.0 KiB
Swift
import Foundation
|
|
import UserNotifications
|
|
|
|
final class NotificationService: UNNotificationServiceExtension {
|
|
private var contentHandler: ((UNNotificationContent) -> Void)?
|
|
private var bestAttemptContent: UNMutableNotificationContent?
|
|
private var resolver: BuzzPushNotificationResolving = BuzzPushNotificationResolver()
|
|
|
|
override func didReceive(
|
|
_ request: UNNotificationRequest,
|
|
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
|
) {
|
|
self.contentHandler = contentHandler
|
|
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
|
contentHandler(request.content)
|
|
return
|
|
}
|
|
bestAttemptContent = content
|
|
|
|
resolver.resolve { [weak self] resolution in
|
|
guard let self else { return }
|
|
if let resolution {
|
|
content.title = resolution.title
|
|
content.body = resolution.body
|
|
if let subtitle = resolution.subtitle {
|
|
content.subtitle = subtitle
|
|
}
|
|
if let threadIdentifier = resolution.threadIdentifier {
|
|
content.threadIdentifier = threadIdentifier
|
|
}
|
|
}
|
|
self.finish(content)
|
|
}
|
|
}
|
|
|
|
override func serviceExtensionTimeWillExpire() {
|
|
if let bestAttemptContent {
|
|
finish(bestAttemptContent)
|
|
}
|
|
}
|
|
|
|
private func finish(_ content: UNNotificationContent) {
|
|
guard let contentHandler else { return }
|
|
self.contentHandler = nil
|
|
contentHandler(content)
|
|
}
|
|
}
|
|
|
|
struct BuzzPushResolution: Decodable {
|
|
let title: String
|
|
let body: String
|
|
let subtitle: String?
|
|
let threadIdentifier: String?
|
|
}
|
|
|
|
protocol BuzzPushNotificationResolving {
|
|
func resolve(completion: @escaping (BuzzPushResolution?) -> Void)
|
|
}
|
|
|
|
final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
|
private let snapshotFile = "push-communities.json"
|
|
private let session: URLSession
|
|
private let appGroupIdentifier: String?
|
|
|
|
init(
|
|
session: URLSession = .shared,
|
|
appGroupIdentifier: String? = Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier")
|
|
as? String
|
|
) {
|
|
self.session = session
|
|
self.appGroupIdentifier = appGroupIdentifier
|
|
}
|
|
|
|
func resolve(completion: @escaping (BuzzPushResolution?) -> Void) {
|
|
guard let community = loadCommunities().first(where: { $0.pubkey?.isEmpty == false }) else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
|
|
// The NIP-PL APNs payload intentionally carries no relay or event id. The
|
|
// service extension therefore performs a bounded catch-up against locally
|
|
// configured origins and only replaces the fixed placeholder if authoritative
|
|
// relay data is available before the NSE deadline.
|
|
let filters: [[String: Any]] = [
|
|
[
|
|
"kinds": [9, 40002, 45001, 45003],
|
|
"#p": [community.pubkey!],
|
|
"limit": 10,
|
|
]
|
|
]
|
|
guard let body = try? JSONSerialization.data(withJSONObject: filters) else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
let url = URL(string: "/query", relativeTo: community.relayURL)!
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.httpBody = body
|
|
|
|
session.dataTask(with: request) { data, response, _ in
|
|
guard let httpResponse = response as? HTTPURLResponse,
|
|
(200..<300).contains(httpResponse.statusCode),
|
|
let data,
|
|
let events = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
|
|
!events.isEmpty
|
|
else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
let resolution = Self.decodeResolution(
|
|
events: events,
|
|
community: community
|
|
)
|
|
completion(resolution)
|
|
}.resume()
|
|
}
|
|
|
|
private static func decodeResolution(events: [[String: Any]], community: BuzzPushCommunity)
|
|
-> BuzzPushResolution?
|
|
{
|
|
guard let myPubkey = community.pubkey?.lowercased() else { return nil }
|
|
let candidates = events.compactMap(BuzzPushEvent.init(json:)).filter { event in
|
|
event.pubkey.lowercased() != myPubkey && [9, 40002, 45001, 45003].contains(event.kind)
|
|
}
|
|
guard
|
|
let event = candidates.sorted(by: { left, right in
|
|
if left.createdAt != right.createdAt { return left.createdAt > right.createdAt }
|
|
return left.id < right.id
|
|
}).first
|
|
else { return nil }
|
|
let body = previewBody(event.content)
|
|
guard !body.isEmpty else { return nil }
|
|
return BuzzPushResolution(
|
|
title: shortPubkey(event.pubkey),
|
|
body: body,
|
|
subtitle: community.name,
|
|
threadIdentifier: event.channelId ?? community.id
|
|
)
|
|
}
|
|
|
|
private static func previewBody(_ content: String) -> String {
|
|
var result = content.replacingOccurrences(
|
|
of: #"```[\s\S]*?```"#,
|
|
with: "[code]",
|
|
options: .regularExpression
|
|
)
|
|
result = result.replacingOccurrences(of: #"`([^`]*)`"#, with: "$1", options: .regularExpression)
|
|
result = result.replacingOccurrences(
|
|
of: #"!\[([^\]]*)\]\([^)]*\)"#, with: "$1", options: .regularExpression)
|
|
result = result.replacingOccurrences(
|
|
of: #"\[([^\]]+)\]\([^)]*\)"#, with: "$1", options: .regularExpression)
|
|
result = result.replacingOccurrences(
|
|
of: #"https?://\S+"#, with: "[link]", options: .regularExpression)
|
|
result = result.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard result.count > 180 else { return result }
|
|
return String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…"
|
|
}
|
|
|
|
private static func shortPubkey(_ pubkey: String) -> String {
|
|
guard pubkey.count > 8 else { return pubkey }
|
|
return String(pubkey.prefix(8)) + "…"
|
|
}
|
|
|
|
private func loadCommunities() -> [BuzzPushCommunity] {
|
|
guard let appGroupIdentifier,
|
|
let container = FileManager.default.containerURL(
|
|
forSecurityApplicationGroupIdentifier: appGroupIdentifier)
|
|
else { return [] }
|
|
let url = container.appendingPathComponent(snapshotFile)
|
|
guard let data = try? Data(contentsOf: url),
|
|
let decoded = try? JSONDecoder().decode(BuzzPushSnapshot.self, from: data)
|
|
else { return [] }
|
|
return decoded.communities
|
|
}
|
|
}
|
|
|
|
struct BuzzPushEvent {
|
|
let id: String
|
|
let pubkey: String
|
|
let createdAt: Int
|
|
let kind: Int
|
|
let tags: [[String]]
|
|
let content: String
|
|
|
|
init?(json: [String: Any]) {
|
|
guard let id = json["id"] as? String,
|
|
let pubkey = json["pubkey"] as? String,
|
|
let createdAt = json["created_at"] as? Int,
|
|
let kind = json["kind"] as? Int,
|
|
let tags = json["tags"] as? [[String]],
|
|
let content = json["content"] as? String
|
|
else { return nil }
|
|
self.id = id
|
|
self.pubkey = pubkey
|
|
self.createdAt = createdAt
|
|
self.kind = kind
|
|
self.tags = tags
|
|
self.content = content
|
|
}
|
|
|
|
var channelId: String? {
|
|
tags.first { $0.count >= 2 && $0[0] == "h" }?[1]
|
|
}
|
|
}
|
|
|
|
struct BuzzPushSnapshot: Decodable {
|
|
let communities: [BuzzPushCommunity]
|
|
}
|
|
|
|
struct BuzzPushCommunity: Decodable {
|
|
let id: String
|
|
let name: String
|
|
let relayUrl: String
|
|
let pubkey: String?
|
|
|
|
var relayURL: URL {
|
|
URL(string: relayUrl) ?? URL(string: "http://127.0.0.1")!
|
|
}
|
|
}
|