mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(mobile/ios): replay lease subscriptions in notification extension
Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Signed-off-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
This commit is contained in:
parent
4e98d17817
commit
a7e2283537
@@ -0,0 +1,199 @@
|
||||
import Foundation
|
||||
|
||||
public enum PushLeaseError: Error, Equatable {
|
||||
case unsupportedAuthority(String)
|
||||
case acceptedAuthorityMissingSubscriptions
|
||||
case emptySubscriptions
|
||||
}
|
||||
|
||||
public struct PushLeaseSnapshot: Codable, Equatable, Sendable {
|
||||
public let communities: [PushLeaseCommunity]
|
||||
|
||||
public init(communities: [PushLeaseCommunity]) {
|
||||
self.communities = communities
|
||||
}
|
||||
}
|
||||
|
||||
public struct PushLeaseCommunity: Codable, Equatable, Sendable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let relayUrl: String
|
||||
public let pubkey: String?
|
||||
public let pushSubscriptionState: PushLeaseSubscriptionState
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
relayUrl: String,
|
||||
pubkey: String?,
|
||||
pushSubscriptionState: PushLeaseSubscriptionState
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.relayUrl = relayUrl
|
||||
self.pubkey = pubkey
|
||||
self.pushSubscriptionState = pushSubscriptionState
|
||||
}
|
||||
}
|
||||
|
||||
public struct PushLeaseSubscriptionState: Codable, Equatable, Sendable {
|
||||
public enum Authority: String, Codable, Sendable {
|
||||
case desired
|
||||
case accepted
|
||||
}
|
||||
|
||||
public let authority: String
|
||||
public let desired: [PushLeaseSubscription]
|
||||
public let accepted: [PushLeaseSubscription]?
|
||||
|
||||
public init(
|
||||
authority: String,
|
||||
desired: [PushLeaseSubscription],
|
||||
accepted: [PushLeaseSubscription]? = nil
|
||||
) {
|
||||
self.authority = authority
|
||||
self.desired = desired
|
||||
self.accepted = accepted
|
||||
}
|
||||
|
||||
/// Workstream A has no lease publisher, so `desired` is the only valid
|
||||
/// authority today. A later publisher must persist the observed accepted
|
||||
/// lease and switch this field explicitly. This keeps relay rejection,
|
||||
/// clamping, or expiry visible rather than assuming acceptance.
|
||||
public func authoritativeSubscriptions() throws -> [PushLeaseSubscription] {
|
||||
let subscriptions: [PushLeaseSubscription]
|
||||
switch authority {
|
||||
case Authority.desired.rawValue:
|
||||
subscriptions = desired
|
||||
case Authority.accepted.rawValue:
|
||||
guard let accepted else {
|
||||
throw PushLeaseError.acceptedAuthorityMissingSubscriptions
|
||||
}
|
||||
subscriptions = accepted
|
||||
default:
|
||||
throw PushLeaseError.unsupportedAuthority(authority)
|
||||
}
|
||||
guard !subscriptions.isEmpty else {
|
||||
throw PushLeaseError.emptySubscriptions
|
||||
}
|
||||
return subscriptions
|
||||
}
|
||||
}
|
||||
|
||||
public struct PushLeaseSubscription: Codable, Equatable, Sendable {
|
||||
public let filter: PushLeaseFilter
|
||||
public let notificationClass: String
|
||||
public let ignore: [PushLeaseFilter]
|
||||
public let suppress: PushLeaseSuppression?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case filter
|
||||
case notificationClass = "class"
|
||||
case ignore
|
||||
case suppress
|
||||
}
|
||||
|
||||
public init(
|
||||
filter: PushLeaseFilter,
|
||||
notificationClass: String,
|
||||
ignore: [PushLeaseFilter] = [],
|
||||
suppress: PushLeaseSuppression? = nil
|
||||
) {
|
||||
self.filter = filter
|
||||
self.notificationClass = notificationClass
|
||||
self.ignore = ignore
|
||||
self.suppress = suppress
|
||||
}
|
||||
}
|
||||
|
||||
public struct PushLeaseSuppression: Codable, Equatable, Sendable {
|
||||
public let pTagsMax: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case pTagsMax = "p_tags_max"
|
||||
}
|
||||
|
||||
public init(pTagsMax: Int) {
|
||||
self.pTagsMax = pTagsMax
|
||||
}
|
||||
}
|
||||
|
||||
public struct PushLeaseFilter: Codable, Equatable, Sendable {
|
||||
public let kinds: [Int]
|
||||
public let authors: [String]?
|
||||
public let pTags: [String]?
|
||||
public let hTags: [String]?
|
||||
public let eTags: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case kinds
|
||||
case authors
|
||||
case pTags = "#p"
|
||||
case hTags = "#h"
|
||||
case eTags = "#e"
|
||||
}
|
||||
|
||||
public init(
|
||||
kinds: [Int],
|
||||
authors: [String]? = nil,
|
||||
pTags: [String]? = nil,
|
||||
hTags: [String]? = nil,
|
||||
eTags: [String]? = nil
|
||||
) {
|
||||
self.kinds = kinds
|
||||
self.authors = authors
|
||||
self.pTags = pTags
|
||||
self.hTags = hTags
|
||||
self.eTags = eTags
|
||||
}
|
||||
|
||||
public func queryFilter(since: Int?, limit: Int) -> [String: Any] {
|
||||
var filter: [String: Any] = ["kinds": kinds, "limit": limit]
|
||||
if let authors { filter["authors"] = authors }
|
||||
if let pTags { filter["#p"] = pTags }
|
||||
if let hTags { filter["#h"] = hTags }
|
||||
if let eTags { filter["#e"] = eTags }
|
||||
if let since { filter["since"] = since }
|
||||
return filter
|
||||
}
|
||||
|
||||
public func matches(_ event: VerifiedNostrEvent) -> Bool {
|
||||
guard kinds.contains(event.kind) else { return false }
|
||||
if let authors, !authors.contains(event.pubkey.lowercased()) { return false }
|
||||
if let pTags, !event.hasAnyTag(named: "p", values: pTags) { return false }
|
||||
if let hTags, !event.hasAnyTag(named: "h", values: hTags) { return false }
|
||||
if let eTags, !event.hasAnyTag(named: "e", values: eTags) { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
public enum PushLeaseMatcher {
|
||||
public static func matches(
|
||||
event: VerifiedNostrEvent,
|
||||
subscription: PushLeaseSubscription
|
||||
) -> Bool {
|
||||
guard subscription.filter.matches(event) else { return false }
|
||||
if subscription.ignore.contains(where: { $0.matches(event) }) { return false }
|
||||
if let maximum = subscription.suppress?.pTagsMax,
|
||||
event.tagCount(named: "p") > maximum
|
||||
{
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension VerifiedNostrEvent {
|
||||
public func tagCount(named name: String) -> Int {
|
||||
tags.reduce(into: 0) { count, tag in
|
||||
if tag.count >= 2 && tag[0] == name { count += 1 }
|
||||
}
|
||||
}
|
||||
|
||||
public func hasAnyTag(named name: String, values: [String]) -> Bool {
|
||||
let expected = Set(values.map { $0.lowercased() })
|
||||
return tags.contains { tag in
|
||||
tag.count >= 2 && tag[0] == name && expected.contains(tag[1].lowercased())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import XCTest
|
||||
|
||||
@testable import BuzzPushKit
|
||||
|
||||
final class PushLeaseTests: XCTestCase {
|
||||
private let mine = String(repeating: "a", count: 64)
|
||||
private let other = String(repeating: "b", count: 64)
|
||||
|
||||
func testDesiredAuthorityIsExplicitAndAcceptedAuthorityRequiresState() throws {
|
||||
let subscription = PushLeaseSubscription(
|
||||
filter: PushLeaseFilter(kinds: [9], pTags: [mine]),
|
||||
notificationClass: "default"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
try PushLeaseSubscriptionState(
|
||||
authority: "desired",
|
||||
desired: [subscription]
|
||||
).authoritativeSubscriptions(),
|
||||
[subscription]
|
||||
)
|
||||
XCTAssertThrowsError(
|
||||
try PushLeaseSubscriptionState(
|
||||
authority: "accepted",
|
||||
desired: [subscription]
|
||||
).authoritativeSubscriptions()
|
||||
) { error in
|
||||
XCTAssertEqual(error as? PushLeaseError, .acceptedAuthorityMissingSubscriptions)
|
||||
}
|
||||
}
|
||||
|
||||
func testFilterBuildsQueryFromLeaseWithoutHardcodedKinds() {
|
||||
let filter = PushLeaseFilter(
|
||||
kinds: [7, 1059],
|
||||
authors: [other],
|
||||
pTags: [mine],
|
||||
hTags: ["channel"],
|
||||
eTags: [String(repeating: "c", count: 64)]
|
||||
)
|
||||
let query = filter.queryFilter(since: 1_000, limit: 10)
|
||||
|
||||
XCTAssertEqual(query["kinds"] as? [Int], [7, 1059])
|
||||
XCTAssertEqual(query["authors"] as? [String], [other])
|
||||
XCTAssertEqual(query["#p"] as? [String], [mine])
|
||||
XCTAssertEqual(query["#h"] as? [String], ["channel"])
|
||||
XCTAssertEqual(query["since"] as? Int, 1_000)
|
||||
}
|
||||
|
||||
func testPushEligibleKindAbsentFromOldConstantMatchesLease() {
|
||||
let event = makeEvent(kind: 1059, tags: [["p", mine]])
|
||||
let subscription = PushLeaseSubscription(
|
||||
filter: PushLeaseFilter(kinds: [1059], pTags: [mine]),
|
||||
notificationClass: "default"
|
||||
)
|
||||
|
||||
XCTAssertTrue(PushLeaseMatcher.matches(event: event, subscription: subscription))
|
||||
}
|
||||
|
||||
func testIgnoreAndHellthreadSuppressionRejectCandidates() {
|
||||
let ignored = makeEvent(kind: 9, pubkey: other, tags: [["p", mine]])
|
||||
let ignoreSubscription = PushLeaseSubscription(
|
||||
filter: PushLeaseFilter(kinds: [9], pTags: [mine]),
|
||||
notificationClass: "default",
|
||||
ignore: [PushLeaseFilter(kinds: [9], authors: [other])]
|
||||
)
|
||||
XCTAssertFalse(
|
||||
PushLeaseMatcher.matches(event: ignored, subscription: ignoreSubscription)
|
||||
)
|
||||
|
||||
let hellthread = makeEvent(
|
||||
kind: 9,
|
||||
tags: (0..<21).map { ["p", String(format: "%064x", $0)] }
|
||||
)
|
||||
let suppressed = PushLeaseSubscription(
|
||||
filter: PushLeaseFilter(kinds: [9], authors: [other]),
|
||||
notificationClass: "default",
|
||||
suppress: PushLeaseSuppression(pTagsMax: 20)
|
||||
)
|
||||
XCTAssertFalse(PushLeaseMatcher.matches(event: hellthread, subscription: suppressed))
|
||||
}
|
||||
|
||||
func testDecodesSnapshotContractFromDartShape() throws {
|
||||
let json = """
|
||||
{"communities":[{"id":"origin","name":"Team","relayUrl":"https://relay.example.com","pubkey":"\(mine)","pushSubscriptionState":{"authority":"desired","desired":[{"filter":{"kinds":[9],"#p":["\(mine)"]},"class":"default","ignore":[{"kinds":[9],"authors":["\(mine)"]}],"suppress":{"p_tags_max":20}}]}}]}
|
||||
"""
|
||||
let snapshot = try JSONDecoder().decode(PushLeaseSnapshot.self, from: Data(json.utf8))
|
||||
|
||||
XCTAssertEqual(snapshot.communities.count, 1)
|
||||
XCTAssertEqual(
|
||||
try snapshot.communities[0].pushSubscriptionState.authoritativeSubscriptions().count,
|
||||
1
|
||||
)
|
||||
}
|
||||
|
||||
private func makeEvent(
|
||||
kind: Int,
|
||||
pubkey: String? = nil,
|
||||
tags: [[String]] = []
|
||||
) -> VerifiedNostrEvent {
|
||||
VerifiedNostrEvent(
|
||||
id: String(repeating: "d", count: 64),
|
||||
pubkey: pubkey ?? other,
|
||||
createdAt: 1_000,
|
||||
kind: kind,
|
||||
tags: tags,
|
||||
content: "message",
|
||||
sig: String(repeating: "e", count: 128)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -19,26 +19,30 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
bestAttemptContent = content
|
||||
|
||||
resolver.resolve { [weak self] resolution in
|
||||
resolver.resolve { [weak self] result in
|
||||
guard let self else { return }
|
||||
if let resolution {
|
||||
switch result {
|
||||
case .notification(let resolution):
|
||||
content.title = resolution.title
|
||||
content.body = resolution.body
|
||||
if let subtitle = resolution.subtitle {
|
||||
content.subtitle = subtitle
|
||||
}
|
||||
content.subtitle = resolution.subtitle ?? ""
|
||||
if let threadIdentifier = resolution.threadIdentifier {
|
||||
content.threadIdentifier = threadIdentifier
|
||||
}
|
||||
case .diagnostic(let message):
|
||||
content.title = "Buzz notification needs attention"
|
||||
content.body = message
|
||||
content.subtitle = ""
|
||||
content.threadIdentifier = "buzz.push.diagnostic"
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
self.finish(content)
|
||||
}
|
||||
}
|
||||
|
||||
override func serviceExtensionTimeWillExpire() {
|
||||
if let bestAttemptContent {
|
||||
finish(bestAttemptContent)
|
||||
}
|
||||
if let bestAttemptContent { finish(bestAttemptContent) }
|
||||
}
|
||||
|
||||
private func finish(_ content: UNNotificationContent) {
|
||||
@@ -48,129 +52,254 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
}
|
||||
|
||||
struct BuzzPushResolution: Decodable {
|
||||
struct BuzzPushResolution {
|
||||
let title: String
|
||||
let body: String
|
||||
let subtitle: String?
|
||||
let threadIdentifier: String?
|
||||
}
|
||||
|
||||
enum BuzzPushResolutionResult {
|
||||
case notification(BuzzPushResolution)
|
||||
case diagnostic(String)
|
||||
case none
|
||||
}
|
||||
|
||||
protocol BuzzPushNotificationResolving {
|
||||
func resolve(completion: @escaping (BuzzPushResolution?) -> Void)
|
||||
func resolve(completion: @escaping (BuzzPushResolutionResult) -> Void)
|
||||
}
|
||||
|
||||
final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
||||
private struct Candidate {
|
||||
let resolution: BuzzPushResolution
|
||||
let event: VerifiedNostrEvent
|
||||
let community: PushLeaseCommunity
|
||||
}
|
||||
|
||||
private let session: URLSession
|
||||
private let appGroupIdentifier: String?
|
||||
private let keychainAccessGroup: String?
|
||||
private let defaults: UserDefaults?
|
||||
private let fileManager: FileManager
|
||||
|
||||
init(
|
||||
session: URLSession = .shared,
|
||||
appGroupIdentifier: String? = Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String,
|
||||
keychainAccessGroup: String? = Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String
|
||||
appGroupIdentifier: String? = Bundle.main.object(
|
||||
forInfoDictionaryKey: "BuzzAppGroupIdentifier"
|
||||
) as? String,
|
||||
keychainAccessGroup: String? = Bundle.main.object(
|
||||
forInfoDictionaryKey: "BuzzKeychainAccessGroup"
|
||||
) as? String,
|
||||
fileManager: FileManager = .default
|
||||
) {
|
||||
self.session = session
|
||||
self.appGroupIdentifier = appGroupIdentifier
|
||||
self.keychainAccessGroup = keychainAccessGroup
|
||||
defaults = appGroupIdentifier.flatMap(UserDefaults.init(suiteName:))
|
||||
self.fileManager = fileManager
|
||||
}
|
||||
|
||||
func resolve(completion: @escaping (BuzzPushResolution?) -> Void) {
|
||||
let loadedCommunities = loadCommunities()
|
||||
func resolve(completion: @escaping (BuzzPushResolutionResult) -> Void) {
|
||||
let loadedCommunities: [PushLeaseCommunity]
|
||||
do {
|
||||
loadedCommunities = try loadCommunities()
|
||||
} catch {
|
||||
completion(.diagnostic("Open Buzz to refresh notification subscriptions."))
|
||||
return
|
||||
}
|
||||
removeStaleWatermarks(activeCommunityIDs: Set(loadedCommunities.map(\.id)))
|
||||
let communities = loadedCommunities.filter {
|
||||
$0.pubkey?.isEmpty == false && loadPrivateKey(communityID: $0.id) != nil
|
||||
}
|
||||
guard !communities.isEmpty else { completion(nil); return }
|
||||
guard !communities.isEmpty else {
|
||||
completion(.diagnostic("Open Buzz to restore notification credentials."))
|
||||
return
|
||||
}
|
||||
|
||||
let group = DispatchGroup()
|
||||
let lock = NSLock()
|
||||
var candidates: [(BuzzPushResolution, VerifiedNostrEvent, BuzzPushCommunity)] = []
|
||||
var candidates: [Candidate] = []
|
||||
var diagnostics: [String] = []
|
||||
for community in communities {
|
||||
group.enter()
|
||||
query(community) { candidate in
|
||||
if let candidate {
|
||||
lock.lock(); candidates.append((candidate.0, candidate.1, community)); lock.unlock()
|
||||
query(community) { result in
|
||||
lock.lock()
|
||||
switch result {
|
||||
case .candidate(let candidate): candidates.append(candidate)
|
||||
case .diagnostic(let diagnostic): diagnostics.append(diagnostic)
|
||||
case .none: break
|
||||
}
|
||||
lock.unlock()
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
group.notify(queue: .global(qos: .userInitiated)) { [weak self] in
|
||||
guard let self else { return }
|
||||
let newest = candidates.max {
|
||||
$0.1.createdAt == $1.1.createdAt ? $0.1.id > $1.1.id : $0.1.createdAt < $1.1.createdAt
|
||||
let sorted = candidates.sorted { lhs, rhs in
|
||||
if lhs.event.createdAt != rhs.event.createdAt {
|
||||
return lhs.event.createdAt > rhs.event.createdAt
|
||||
}
|
||||
if lhs.event.id != rhs.event.id { return lhs.event.id < rhs.event.id }
|
||||
return lhs.community.id < rhs.community.id
|
||||
}
|
||||
guard let winner = sorted.first else {
|
||||
completion(diagnostics.sorted().first.map(BuzzPushResolutionResult.diagnostic) ?? .none)
|
||||
return
|
||||
}
|
||||
for candidate in candidates {
|
||||
self.defaults?.set(
|
||||
PushWatermark.persistedTimestamp(eventTimestamp: candidate.1.createdAt),
|
||||
forKey: PushWatermark.key(communityID: candidate.2.id)
|
||||
PushWatermark.persistedTimestamp(eventTimestamp: candidate.event.createdAt),
|
||||
forKey: PushWatermark.key(communityID: candidate.community.id)
|
||||
)
|
||||
}
|
||||
completion(newest?.0)
|
||||
completion(.notification(winner.resolution))
|
||||
}
|
||||
}
|
||||
|
||||
private enum QueryResult {
|
||||
case candidate(Candidate)
|
||||
case diagnostic(String)
|
||||
case none
|
||||
}
|
||||
|
||||
private func query(
|
||||
_ community: BuzzPushCommunity,
|
||||
completion: @escaping ((BuzzPushResolution, VerifiedNostrEvent)?) -> Void
|
||||
_ community: PushLeaseCommunity,
|
||||
completion: @escaping (QueryResult) -> Void
|
||||
) {
|
||||
guard let privateKey = loadPrivateKey(communityID: community.id), let pubkey = community.pubkey else {
|
||||
completion(nil); return
|
||||
guard let privateKey = loadPrivateKey(communityID: community.id) else {
|
||||
completion(.diagnostic("Open Buzz to restore notification credentials."))
|
||||
return
|
||||
}
|
||||
let subscriptions: [PushLeaseSubscription]
|
||||
do {
|
||||
subscriptions = try community.pushSubscriptionState.authoritativeSubscriptions()
|
||||
} catch {
|
||||
completion(.diagnostic("Open Buzz to refresh notification subscriptions."))
|
||||
return
|
||||
}
|
||||
var filter: [String: Any] = ["kinds": [9, 40002, 45001, 45003], "#p": [pubkey], "limit": 10]
|
||||
let watermarkKey = PushWatermark.key(communityID: community.id)
|
||||
let storedWatermark = defaults?.integer(forKey: watermarkKey) ?? 0
|
||||
let watermark = PushWatermark.queryTimestamp(storedWatermark: storedWatermark)
|
||||
if watermark != storedWatermark { defaults?.set(watermark, forKey: watermarkKey) }
|
||||
if let since = PushWatermark.querySince(watermark: watermark) { filter["since"] = since }
|
||||
guard let body = try? JSONSerialization.data(withJSONObject: [filter]) else { completion(nil); return }
|
||||
let url = URL(string: "/query", relativeTo: community.relayURL)!
|
||||
let since = PushWatermark.querySince(watermark: watermark)
|
||||
let filters = subscriptions.map { $0.filter.queryFilter(since: since, limit: 10) }
|
||||
guard let body = try? JSONSerialization.data(withJSONObject: filters) else {
|
||||
completion(.diagnostic("Buzz notification subscriptions are invalid."))
|
||||
return
|
||||
}
|
||||
guard let relayURL = community.relayURL,
|
||||
let url = URL(string: "/query", relativeTo: relayURL)
|
||||
else {
|
||||
completion(.diagnostic("Buzz notification relay URL is invalid."))
|
||||
return
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"; request.httpBody = body; request.timeoutInterval = 8
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = body
|
||||
request.timeoutInterval = 8
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
guard let auth = try? NostrHTTPAuth.authorizationHeader(
|
||||
url: url, method: "POST", body: body, privateKeyHex: privateKey
|
||||
) else { completion(nil); return }
|
||||
guard
|
||||
let auth = try? NostrHTTPAuth.authorizationHeader(
|
||||
url: url,
|
||||
method: "POST",
|
||||
body: body,
|
||||
privateKeyHex: privateKey
|
||||
)
|
||||
else {
|
||||
completion(.diagnostic("Buzz could not authenticate notification catch-up."))
|
||||
return
|
||||
}
|
||||
request.setValue(auth, forHTTPHeaderField: "Authorization")
|
||||
session.dataTask(with: request) { data, response, _ in
|
||||
guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode),
|
||||
let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
|
||||
else { completion(nil); return }
|
||||
completion(Self.decodeResolution(
|
||||
events: events.filter {
|
||||
$0.hasValidIDAndSignature() && PushWatermark.isAcceptable(eventTimestamp: $0.createdAt)
|
||||
},
|
||||
community: community
|
||||
))
|
||||
guard let response = response as? HTTPURLResponse,
|
||||
(200..<300).contains(response.statusCode),
|
||||
let data,
|
||||
let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
|
||||
else {
|
||||
completion(.none)
|
||||
return
|
||||
}
|
||||
completion(
|
||||
Self.decodeResolution(
|
||||
events: events,
|
||||
community: community,
|
||||
subscriptions: subscriptions
|
||||
)
|
||||
)
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private static func decodeResolution(
|
||||
events: [VerifiedNostrEvent], community: BuzzPushCommunity
|
||||
) -> (BuzzPushResolution, VerifiedNostrEvent)? {
|
||||
guard let mine = community.pubkey?.lowercased() else { return nil }
|
||||
let event = events.filter {
|
||||
$0.pubkey.lowercased() != mine && [9, 40002, 45001, 45003].contains($0.kind)
|
||||
events: [VerifiedNostrEvent],
|
||||
community: PushLeaseCommunity,
|
||||
subscriptions: [PushLeaseSubscription]
|
||||
) -> QueryResult {
|
||||
let matching = events.filter { event in
|
||||
event.hasValidIDAndSignature()
|
||||
&& PushWatermark.isAcceptable(eventTimestamp: event.createdAt)
|
||||
&& subscriptions.contains(where: {
|
||||
PushLeaseMatcher.matches(event: event, subscription: $0)
|
||||
})
|
||||
}.sorted {
|
||||
$0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt
|
||||
}.first
|
||||
guard let event else { return nil }
|
||||
let body = previewBody(event.content)
|
||||
guard !body.isEmpty else { return nil }
|
||||
let channel = event.tags.first { $0.count >= 2 && $0[0] == "h" }?[1]
|
||||
return (BuzzPushResolution(
|
||||
title: shortPubkey(event.pubkey), body: body, subtitle: community.name,
|
||||
threadIdentifier: channel ?? community.id
|
||||
), event)
|
||||
}
|
||||
|
||||
for event in matching {
|
||||
let resolution: BuzzPushResolution
|
||||
if event.kind == 9 {
|
||||
let body = previewBody(event.content)
|
||||
guard !body.isEmpty else { continue }
|
||||
let channel = event.tags.first { $0.count >= 2 && $0[0] == "h" }?[1]
|
||||
resolution = BuzzPushResolution(
|
||||
title: shortPubkey(event.pubkey),
|
||||
body: body,
|
||||
subtitle: community.name,
|
||||
threadIdentifier: channel ?? community.id
|
||||
)
|
||||
} else {
|
||||
// Wake-only kinds are fetched from the authoritative lease, but this
|
||||
// extension cannot render them yet.
|
||||
resolution = BuzzPushResolution(
|
||||
title: "Buzz notification needs attention",
|
||||
body: "Buzz received a kind \(event.kind) wake that this app version cannot display.",
|
||||
subtitle: community.name,
|
||||
threadIdentifier: "buzz.push.unsupported-kind.\(event.kind)"
|
||||
)
|
||||
}
|
||||
return .candidate(Candidate(resolution: resolution, event: event, community: community))
|
||||
}
|
||||
return .none
|
||||
}
|
||||
|
||||
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: #"https?://\S+"#, with: "[link]", options: .regularExpression)
|
||||
result = result.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return result.count > 180 ? String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…" : result
|
||||
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: #"https?://\S+"#,
|
||||
with: "[link]",
|
||||
options: .regularExpression
|
||||
)
|
||||
result = result.replacingOccurrences(
|
||||
of: #"\s+"#,
|
||||
with: " ",
|
||||
options: .regularExpression
|
||||
).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return result.count > 180
|
||||
? String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…"
|
||||
: result
|
||||
}
|
||||
|
||||
private static func shortPubkey(_ pubkey: String) -> String {
|
||||
@@ -195,34 +324,38 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
if let keychainAccessGroup, !keychainAccessGroup.isEmpty { query[kSecAttrAccessGroup as String] = keychainAccessGroup }
|
||||
if let keychainAccessGroup, !keychainAccessGroup.isEmpty {
|
||||
query[kSecAttrAccessGroup as String] = keychainAccessGroup
|
||||
}
|
||||
var item: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
||||
let data = item as? Data else { return nil }
|
||||
let data = item as? Data
|
||||
else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func loadCommunities() -> [BuzzPushCommunity] {
|
||||
private func loadCommunities() throws -> [PushLeaseCommunity] {
|
||||
guard let appGroupIdentifier,
|
||||
let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier),
|
||||
let data = try? Data(contentsOf: container.appendingPathComponent("push-communities.json")),
|
||||
let decoded = try? JSONDecoder().decode(BuzzPushSnapshot.self, from: data)
|
||||
else { return [] }
|
||||
return decoded.communities
|
||||
let container = fileManager.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: appGroupIdentifier
|
||||
)
|
||||
else {
|
||||
throw CocoaError(.fileNoSuchFile)
|
||||
}
|
||||
let snapshotURL = container.appendingPathComponent("push-communities.json")
|
||||
let data = try Data(contentsOf: snapshotURL)
|
||||
return try JSONDecoder().decode(PushLeaseSnapshot.self, from: data).communities
|
||||
}
|
||||
}
|
||||
|
||||
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")!
|
||||
extension PushLeaseCommunity {
|
||||
fileprivate var relayURL: URL? {
|
||||
guard let url = URL(string: relayUrl),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
scheme == "https" || scheme == "http"
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user