mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
## Summary
iOS users were seeing a run of push notifications that just said
"Reconnect to your relay now". That is the push gateway's fixed APNs
body. It shows when the Notification Service Extension (NSE) can't fetch
the message from the relay.
On-device logs from one affected iPhone over a full day showed 12 of 385
NSE runs fail to resolve. Each failed relay `/query`: 10 hit the 8
second timeout and 2 got `NSURLErrorNetworkConnectionLost` (-1005). All
12 ran on a reused pooled connection from `URLSession.shared` in a
long-lived NSE process. That process lived for hours while the network
path changed between VPN and cellular. Of the fresh connections, 0 of
473 failed.
This change:
- Gives each notification its own ephemeral session, so it never
inherits a pooled connection. The session is cancelled when the
extension finishes or expires.
- Retries `/query` once if it times out or loses its connection
mid-request. The retry uses a new session and a freshly signed NIP-98
proof, because the relay rejects a replayed proof. Other failures (HTTP
errors, offline, cancellation) are not retried. The retry is bounded to
one attempt, so the worst case is about 16 seconds, inside the NSE's
roughly 30 second budget.
- Replaces the gateway body with "You have a new message." when the NSE
can't resolve the push. The restricted fallback ("Open Buzz to view this
message.") is unchanged, and so is the gateway payload.
### Related issue
N/A. Reported in Buzz #buzz-mobile.
### Validation
- Added tests in `BuzzPushNotificationResolverTests`:
- A timeout or -1005 is retried once, on a new session and with a
different `Authorization` proof, and resolves.
- Repeated timeouts stop after 2 attempts.
- `notConnectedToInternet` is not retried.
- The retry is skipped if the account key changed during the first
attempt.
- `cancel()` (called when the NSE finishes or expires) stops a pending
retry.
- Each new test fails when the behavior it covers is removed: the retry,
the new session, the account recheck, or the cancel guard.
- The full BuzzPushKit `swift test` suite passes.
- A debug simulator build of Runner and the NotificationService
extension succeeded.
- An agent review of the diff found no blockers. Its suggestions to
create the session per `didReceive` and to cancel it on expiry are
included.
- Limitations:
- The NSE target has no unit tests, so the fallback text and the session
lifecycle are covered only by build and review.
- I couldn't send a real push through the NSE. Test phones run a
personal-team bundle that isn't the gateway's APNs topic. In the
simulator, `simctl push` needs notification permission, which the app
only requests after joining a community.
- Under the mobile testing exception, the human-test step does not
apply.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
579 lines
21 KiB
Swift
579 lines
21 KiB
Swift
import Foundation
|
|
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
/// Content resolved from unread Buzz events for a mutable push notification.
|
|
public struct BuzzPushResolution: Decodable, Equatable, Sendable {
|
|
/// Local sign-in that authorized this result; checked again before delivery.
|
|
public let notificationScope: BuzzNotificationCacheScope?
|
|
public let title: String
|
|
public let body: String
|
|
public let subtitle: String?
|
|
public let threadIdentifier: String?
|
|
public let navigationTarget: BuzzPushNavigationTarget?
|
|
public let senderPubkey: String?
|
|
public let senderAvatarPNG: Data?
|
|
public let conversationIdentifier: String?
|
|
public let conversationDisplayName: String?
|
|
/// Exact verified recipient count for Communication Notifications specialization.
|
|
public let conversationRecipientCount: Int?
|
|
|
|
public init(
|
|
title: String,
|
|
body: String,
|
|
subtitle: String?,
|
|
threadIdentifier: String?,
|
|
navigationTarget: BuzzPushNavigationTarget? = nil,
|
|
senderPubkey: String? = nil,
|
|
senderAvatarPNG: Data? = nil,
|
|
conversationIdentifier: String? = nil,
|
|
conversationDisplayName: String? = nil,
|
|
conversationRecipientCount: Int? = nil,
|
|
notificationScope: BuzzNotificationCacheScope? = nil
|
|
) {
|
|
self.notificationScope = notificationScope
|
|
self.title = title
|
|
self.body = body
|
|
self.subtitle = subtitle
|
|
self.threadIdentifier = threadIdentifier
|
|
self.navigationTarget = navigationTarget
|
|
self.senderPubkey = senderPubkey
|
|
self.senderAvatarPNG = senderAvatarPNG
|
|
self.conversationIdentifier = conversationIdentifier
|
|
self.conversationDisplayName = conversationDisplayName
|
|
self.conversationRecipientCount = conversationRecipientCount
|
|
}
|
|
}
|
|
|
|
/// Resolves the content used to mutate a generic Buzz push notification.
|
|
public protocol BuzzPushNotificationResolving {
|
|
func resolve(completion: @escaping (BuzzPushResolution?) -> Void)
|
|
/// Cancels any retry in flight and prevents new ones. Call when the notification is delivered.
|
|
func cancel()
|
|
}
|
|
|
|
/// Reads configured Buzz communities and resolves their newest unread event.
|
|
public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
|
// Each independent refresh has a hard transfer limit, allowing bounded
|
|
// content plus JSON escaping, tags, and envelopes.
|
|
// Each record still passes the cache's separate content and signature checks.
|
|
static let maximumPresentationResponseBytes = 2 * 1_024 * 1_024
|
|
|
|
private let session: URLSession
|
|
private let makeRetrySession: () -> URLSession
|
|
// Recursive because a retry that fails synchronously completes while the lock is held,
|
|
// and that completion may finish the notification and call cancel().
|
|
private let retryLock = NSRecursiveLock()
|
|
private var isCancelled = false
|
|
private var retrySessions: [URLSession] = []
|
|
private let loadCommunitiesData: () -> Data?
|
|
private let loadPrivateKey: (String) -> String?
|
|
private let now: () -> Date
|
|
private let notificationCache: BuzzNotificationCache?
|
|
private let avatarLoader: BuzzNotificationAvatarLoader?
|
|
|
|
/// Creates a resolver around the notification extension's App Group and Keychain I/O.
|
|
public init(
|
|
session: URLSession,
|
|
loadCommunitiesData: @escaping () -> Data?,
|
|
loadPrivateKey: @escaping (String) -> String?,
|
|
notificationCache: BuzzNotificationCache? = nil,
|
|
avatarLoader: BuzzNotificationAvatarLoader? = nil,
|
|
now: @escaping () -> Date = Date.init,
|
|
makeRetrySession: (() -> URLSession)? = nil
|
|
) {
|
|
self.makeRetrySession = makeRetrySession ?? { URLSession(configuration: session.configuration) }
|
|
self.notificationCache = notificationCache
|
|
self.avatarLoader = avatarLoader
|
|
self.session = session
|
|
self.loadCommunitiesData = loadCommunitiesData
|
|
self.loadPrivateKey = loadPrivateKey
|
|
self.now = now
|
|
}
|
|
|
|
public func resolve(completion: @escaping (BuzzPushResolution?) -> Void) {
|
|
let communities = loadCommunities().filter {
|
|
$0.pubkey?.isEmpty == false
|
|
&& accountPrivateKey(for: $0) != nil
|
|
&& !$0.policies.isEmpty
|
|
}
|
|
guard !communities.isEmpty else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
let group = DispatchGroup()
|
|
let lock = NSLock()
|
|
var candidates: [(VerifiedNostrEvent, PushLeaseCommunity)] = []
|
|
for community in communities {
|
|
group.enter()
|
|
query(community) { candidate in
|
|
if let candidate {
|
|
lock.lock()
|
|
candidates.append(candidate)
|
|
lock.unlock()
|
|
}
|
|
group.leave()
|
|
}
|
|
}
|
|
group.notify(queue: .global(qos: .userInitiated)) {
|
|
let newest = candidates.max {
|
|
$0.0.createdAt == $1.0.createdAt ? $0.0.id > $1.0.id : $0.0.createdAt < $1.0.createdAt
|
|
}
|
|
guard let newest else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
self.resolvePresentation(event: newest.0, community: newest.1, completion: completion)
|
|
}
|
|
}
|
|
|
|
private func matchingPrivateKey(for community: PushLeaseCommunity) -> String? {
|
|
guard let scope = BuzzNotificationCacheScope(community: community),
|
|
loadCommunities().contains(where: { BuzzNotificationCacheScope(community: $0) == scope })
|
|
else { return nil }
|
|
return accountPrivateKey(for: community)
|
|
}
|
|
|
|
// Initial requests share the snapshot loaded by resolve. Asynchronous results
|
|
// use matchingPrivateKey to check that the account and session are still current.
|
|
private func accountPrivateKey(for community: PushLeaseCommunity) -> String? {
|
|
guard let scope = BuzzNotificationCacheScope(community: community),
|
|
let key = loadPrivateKey(community.id), scope.matchesAccountKey(key)
|
|
else { return nil }
|
|
return key
|
|
}
|
|
|
|
private func query(
|
|
_ community: PushLeaseCommunity,
|
|
completion: @escaping ((VerifiedNostrEvent, PushLeaseCommunity)?) -> Void
|
|
) {
|
|
guard let privateKey = accountPrivateKey(for: community), community.pubkey?.isEmpty == false else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
guard
|
|
!community.policies.isEmpty,
|
|
let relayURL = community.relayURL,
|
|
let url = URL(string: "/query", relativeTo: relayURL),
|
|
let body = try? JSONSerialization.data(
|
|
withJSONObject: community.policies.map { $0.filter.queryFilter(since: nil, limit: 10) }
|
|
)
|
|
else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
send(
|
|
url: url, body: body, privateKey: privateKey, session: session,
|
|
canRetry: { self.matchingPrivateKey(for: community) == privateKey }
|
|
) { data, response in
|
|
guard self.matchingPrivateKey(for: community) != nil,
|
|
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
|
|
}
|
|
let candidate = Self.newestMessage(
|
|
events: events.filter { event in
|
|
event.hasValidIDAndSignature()
|
|
&& community.policies.contains { policy in
|
|
PushLeaseMatcher.matches(event: event, policy: policy)
|
|
}
|
|
},
|
|
community: community
|
|
)
|
|
completion(candidate.map { ($0, community) })
|
|
}
|
|
}
|
|
|
|
/// Sends one signed `/query`. A timeout or dropped connection is retried once on a new
|
|
/// session, because the failed attempt may have used a pooled connection that went stale
|
|
/// when the network path changed. Each attempt is signed afresh so the relay's NIP-98
|
|
/// replay guard accepts it. `canRetry` rechecks the account first; nil means no retry.
|
|
public func cancel() {
|
|
retryLock.lock()
|
|
isCancelled = true
|
|
let sessions = retrySessions
|
|
retrySessions = []
|
|
retryLock.unlock()
|
|
sessions.forEach { $0.invalidateAndCancel() }
|
|
}
|
|
|
|
private func send(
|
|
url: URL, body: Data, privateKey: String, session: URLSession, canRetry: (() -> Bool)?,
|
|
completion: @escaping (Data?, URLResponse?) -> Void
|
|
) {
|
|
var request = URLRequest(url: url)
|
|
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, nil)
|
|
return
|
|
}
|
|
request.setValue(auth, forHTTPHeaderField: "Authorization")
|
|
session.dataTask(with: request) { data, response, error in
|
|
guard let error = error as? URLError,
|
|
[.timedOut, .networkConnectionLost].contains(error.code), canRetry?() == true
|
|
else {
|
|
completion(data, response)
|
|
return
|
|
}
|
|
// Creating the retry under the lock means cancel() either stops it or sees its session.
|
|
self.retryLock.lock()
|
|
guard !self.isCancelled else {
|
|
self.retryLock.unlock()
|
|
completion(data, response)
|
|
return
|
|
}
|
|
defer { self.retryLock.unlock() }
|
|
let retrySession = self.makeRetrySession()
|
|
self.retrySessions.append(retrySession)
|
|
self.send(
|
|
url: url, body: body, privateKey: privateKey, session: retrySession, canRetry: nil
|
|
) { data, response in
|
|
retrySession.finishTasksAndInvalidate()
|
|
completion(data, response)
|
|
}
|
|
}.resume()
|
|
}
|
|
|
|
private func resolvePresentation(
|
|
event: VerifiedNostrEvent,
|
|
community: PushLeaseCommunity,
|
|
completion: @escaping (BuzzPushResolution?) -> Void
|
|
) {
|
|
guard matchingPrivateKey(for: community) != nil else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
if let notificationCache {
|
|
guard let scope = BuzzNotificationCacheScope(community: community) else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
let operation = BuzzNotificationEnrichmentOperation(
|
|
event: event, community: community, scope: scope, cache: notificationCache,
|
|
avatarLoader: avatarLoader, now: now,
|
|
isCurrent: { [self] in
|
|
matchingPrivateKey(for: community) != nil
|
|
},
|
|
refresh: { [self] profile, channel, done in
|
|
refreshPresentation(event: event, community: community,
|
|
refreshProfile: profile, refreshChannel: channel, completion: done)
|
|
}, completion: completion)
|
|
operation.start()
|
|
return
|
|
}
|
|
// Without extension storage, keep the verified message but omit enrichment.
|
|
completion(Self.makeResolution(event: event, community: community, profile: nil, channel: nil))
|
|
}
|
|
|
|
private func refreshPresentation(
|
|
event: VerifiedNostrEvent, community: PushLeaseCommunity,
|
|
refreshProfile: Bool, refreshChannel: Bool,
|
|
completion: @escaping (BuzzNotificationEnrichmentOperation.RefreshResult) -> Void
|
|
) {
|
|
// A full channel roster can be much larger than a profile. Keep these
|
|
// transfers independent so its size or latency cannot hide the sender.
|
|
if refreshProfile {
|
|
queryPresentation(
|
|
filters: [["kinds": [0], "authors": [event.pubkey.lowercased()], "limit": 1]],
|
|
community: community
|
|
) { events in
|
|
completion(.profile(Self.newest(events.filter {
|
|
$0.kind == 0 && $0.pubkey.lowercased() == event.pubkey.lowercased()
|
|
})))
|
|
}
|
|
}
|
|
if refreshChannel {
|
|
guard let channelID = Self.tagValue("h", in: event),
|
|
let authority = community.relayMetadataPubkey?.lowercased()
|
|
else {
|
|
completion(.channel(nil, nil))
|
|
return
|
|
}
|
|
let group = DispatchGroup()
|
|
let lock = NSLock()
|
|
var records: [Int: VerifiedNostrEvent] = [:]
|
|
for kind in [39000, 39002] {
|
|
group.enter()
|
|
queryPresentation(filters: [[
|
|
"kinds": [kind], "authors": [authority], "#d": [channelID], "limit": 1,
|
|
]], community: community) { events in
|
|
let record = Self.newest(events.filter {
|
|
$0.kind == kind && $0.pubkey.lowercased() == authority
|
|
&& Self.tagValue("d", in: $0) == channelID
|
|
})
|
|
lock.lock()
|
|
records[kind] = record
|
|
lock.unlock()
|
|
group.leave()
|
|
}
|
|
}
|
|
group.notify(queue: .global(qos: .utility)) {
|
|
completion(.channel(records[39000], records[39002]))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func queryPresentation(
|
|
filters: [[String: Any]], community: PushLeaseCommunity,
|
|
completion: @escaping ([VerifiedNostrEvent]) -> Void
|
|
) {
|
|
guard let privateKey = matchingPrivateKey(for: community),
|
|
let relayURL = community.relayURL,
|
|
let url = URL(string: "/query", relativeTo: relayURL),
|
|
let body = try? JSONSerialization.data(withJSONObject: filters)
|
|
else {
|
|
completion([])
|
|
return
|
|
}
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.httpBody = body
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
guard let auth = try? NostrHTTPAuth.authorizationHeader(
|
|
url: url, method: "POST", body: body, privateKeyHex: privateKey)
|
|
else {
|
|
completion([])
|
|
return
|
|
}
|
|
request.setValue(auth, forHTTPHeaderField: "Authorization")
|
|
BuzzNotificationDownload.start(
|
|
request: request, configuration: session.configuration,
|
|
maximumBytes: Self.maximumPresentationResponseBytes
|
|
) { result in
|
|
guard self.matchingPrivateKey(for: community) != nil,
|
|
case .success(let data) = result,
|
|
let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
|
|
else {
|
|
completion([])
|
|
return
|
|
}
|
|
completion(events.filter { $0.hasValidIDAndSignature() })
|
|
}
|
|
}
|
|
|
|
static func decodeResolution(
|
|
events: [VerifiedNostrEvent], community: PushLeaseCommunity
|
|
) -> (BuzzPushResolution, VerifiedNostrEvent)? {
|
|
let event = newestMessage(events: events, community: community)
|
|
guard let event else { return nil }
|
|
guard
|
|
let resolution = makeResolution(
|
|
event: event,
|
|
community: community,
|
|
profile: nil,
|
|
channel: nil
|
|
)
|
|
else { return nil }
|
|
return (resolution, event)
|
|
}
|
|
|
|
private static func newestMessage(
|
|
events: [VerifiedNostrEvent],
|
|
community: PushLeaseCommunity
|
|
) -> VerifiedNostrEvent? {
|
|
guard let mine = community.pubkey?.lowercased() else { return nil }
|
|
return events.filter {
|
|
$0.pubkey.lowercased() != mine && [9, 40002, 45001, 45003].contains($0.kind)
|
|
}.sorted {
|
|
$0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt
|
|
}.first
|
|
}
|
|
|
|
static func makeResolution(
|
|
event: VerifiedNostrEvent,
|
|
community: PushLeaseCommunity,
|
|
profile: BuzzPushCachedProfile?,
|
|
channel: BuzzPushCachedChannel?
|
|
) -> BuzzPushResolution? {
|
|
let body = previewBody(event.content)
|
|
guard !body.isEmpty else { return nil }
|
|
let channelID = tagValue("h", in: event)
|
|
let conversationIdentifier = channelID.map {
|
|
BuzzPushPresentationIdentity.conversation(communityID: community.id, channelID: $0)
|
|
}
|
|
let conversation = channel.flatMap {
|
|
communicationConversation(event: event, community: community, channel: $0)
|
|
}
|
|
return BuzzPushResolution(
|
|
title: profile?.displayName ?? "",
|
|
body: body,
|
|
subtitle: community.name,
|
|
threadIdentifier: conversationIdentifier ?? community.id,
|
|
navigationTarget: channelID.map {
|
|
BuzzPushNavigationTarget(
|
|
eventID: event.id,
|
|
communityID: community.id,
|
|
channelID: $0,
|
|
threadRootID: BuzzPushNavigationTarget.threadRootID(in: event.tags)
|
|
)
|
|
},
|
|
senderPubkey: event.pubkey.lowercased(),
|
|
senderAvatarPNG: profile?.avatarPNG,
|
|
conversationIdentifier: conversationIdentifier,
|
|
conversationDisplayName: conversation?.displayName,
|
|
conversationRecipientCount: conversation?.recipientCount,
|
|
notificationScope: BuzzNotificationCacheScope(community: community)
|
|
)
|
|
}
|
|
|
|
static func ephemeralChannel(
|
|
metadataEvent: VerifiedNostrEvent?,
|
|
membershipEvent: VerifiedNostrEvent?,
|
|
cached: BuzzPushCachedChannel?,
|
|
communityID: String,
|
|
relayOrigin: String,
|
|
relayMetadataPubkey: String,
|
|
cachedAt: Int
|
|
) -> BuzzPushCachedChannel? {
|
|
guard metadataEvent != nil || cached != nil else { return nil }
|
|
let channelID = metadataEvent.flatMap { tagValue("d", in: $0) } ?? cached?.channelID
|
|
guard let channelID, !channelID.isEmpty,
|
|
let eventID = metadataEvent?.id ?? cached?.eventID,
|
|
let eventCreatedAt = metadataEvent?.createdAt ?? cached?.eventCreatedAt,
|
|
let metadataCachedAt = metadataEvent == nil ? cached?.cachedAt : cachedAt
|
|
else { return nil }
|
|
let membership = membershipEvent.flatMap {
|
|
BuzzPushPresentationCacheStore.normalizedChannelMembership(
|
|
$0,
|
|
communityID: communityID,
|
|
channelID: channelID
|
|
)
|
|
}
|
|
let acceptedMembershipEvent = membership == nil ? nil : membershipEvent
|
|
return BuzzPushCachedChannel(
|
|
communityID: communityID,
|
|
relayOrigin: relayOrigin,
|
|
channelID: channelID,
|
|
relayMetadataPubkey: relayMetadataPubkey,
|
|
displayName: metadataEvent.map {
|
|
BuzzPushPresentationCacheStore.normalizedDisplayName(tagValue("name", in: $0))
|
|
} ?? cached?.displayName,
|
|
channelType: metadataEvent.map {
|
|
BuzzPushPresentationCacheStore.normalizedChannelType(tagValue("t", in: $0))
|
|
} ?? cached?.channelType,
|
|
memberCount: membership?.count ?? cached?.memberCount,
|
|
memberDigests: membership?.digests ?? cached?.memberDigests,
|
|
membershipEventID: acceptedMembershipEvent?.id ?? cached?.membershipEventID,
|
|
membershipEventCreatedAt: acceptedMembershipEvent?.createdAt
|
|
?? cached?.membershipEventCreatedAt,
|
|
membershipCachedAt: acceptedMembershipEvent == nil
|
|
? cached?.membershipCachedAt : cachedAt,
|
|
eventID: eventID,
|
|
eventCreatedAt: eventCreatedAt,
|
|
cachedAt: metadataCachedAt
|
|
)
|
|
}
|
|
|
|
private static func communicationConversation(
|
|
event: VerifiedNostrEvent,
|
|
community: PushLeaseCommunity,
|
|
channel: BuzzPushCachedChannel
|
|
) -> (displayName: String?, recipientCount: Int)? {
|
|
guard let currentUser = community.pubkey?.lowercased(),
|
|
let memberCount = channel.memberCount,
|
|
let memberDigests = channel.memberDigests,
|
|
memberDigests.count == memberCount
|
|
else { return nil }
|
|
let currentUserDigest = BuzzPushPresentationIdentity.channelMember(
|
|
communityID: community.id,
|
|
channelID: channel.channelID,
|
|
pubkey: currentUser
|
|
)
|
|
guard memberDigests.contains(currentUserDigest) else { return nil }
|
|
let senderDigest = BuzzPushPresentationIdentity.channelMember(
|
|
communityID: community.id,
|
|
channelID: channel.channelID,
|
|
pubkey: event.pubkey
|
|
)
|
|
let senderIsMember = memberDigests.contains(senderDigest)
|
|
let recipientCount = memberCount - (senderIsMember ? 1 : 0)
|
|
guard recipientCount > 0 else { return nil }
|
|
|
|
if channel.channelType == "dm" {
|
|
guard memberCount == 2, senderIsMember else { return nil }
|
|
return (nil, recipientCount)
|
|
}
|
|
guard let channelType = channel.channelType,
|
|
["stream", "forum"].contains(channelType),
|
|
let displayName = channel.displayName
|
|
else { return nil }
|
|
return (displayName.hasPrefix("#") ? displayName : "#\(displayName)", recipientCount)
|
|
}
|
|
|
|
private static func newest(_ events: [VerifiedNostrEvent]) -> VerifiedNostrEvent? {
|
|
events.sorted {
|
|
$0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt
|
|
}.first
|
|
}
|
|
|
|
static func tagValue(_ name: String, in event: VerifiedNostrEvent) -> String? {
|
|
event.tags.first { $0.count >= 2 && $0[0] == name }?[1]
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/// Compact sender label for unnamed senders: the first 8 and last 4
|
|
/// characters of the sender's full npub, matching the compact npub shape
|
|
/// used across Buzz. A key that cannot be encoded as an npub falls back to
|
|
/// the neutral "Someone" identity so malformed payloads leak no key
|
|
/// material while the notification keeps a useful title.
|
|
static func shortPubkey(_ pubkey: String) -> String {
|
|
guard let npub = Bech32.canonicalNpub(from: pubkey) else { return "Someone" }
|
|
return npub.count > 12
|
|
? String(npub.prefix(8)) + "…" + String(npub.suffix(4)) : npub
|
|
}
|
|
|
|
private func loadCommunities() -> [PushLeaseCommunity] {
|
|
guard let data = loadCommunitiesData(),
|
|
let decoded = try? JSONDecoder().decode(PushLeaseSnapshot.self, from: data)
|
|
else { return [] }
|
|
return decoded.communities
|
|
}
|
|
}
|
|
|
|
extension PushLeaseCommunity {
|
|
var relayURL: URL? {
|
|
guard var components = URLComponents(string: relayUrl),
|
|
components.host?.isEmpty == false,
|
|
components.user == nil,
|
|
components.password == nil,
|
|
components.query == nil,
|
|
components.fragment == nil,
|
|
components.path.isEmpty || components.path == "/"
|
|
else { return nil }
|
|
components.scheme =
|
|
switch components.scheme?.lowercased() {
|
|
case "wss": "https"
|
|
case "ws": "http"
|
|
case "https": "https"
|
|
case "http": "http"
|
|
default: nil
|
|
}
|
|
components.path = ""
|
|
return components.url
|
|
}
|
|
}
|