Files
buzz/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift
Tom Brow fea02d174d fix(ios): fetch profiles and avatars in notification extension (#8369)
A notification can arrive from someone whose profile the main app has
never loaded. The iOS notification extension now fetches and caches
sender profiles, channel details, and avatars itself. The main app
supplies account configuration and Keychain credentials only.

After resolving the message, the extension allows 1.5 seconds for sender
and channel details. It uses available stale data when a refresh is slow
or fails, delivers once, and lets late results update its private cache.
Profiles, channel details, and avatar refresh checks share a one-hour
freshness threshold. A verified avatar removal clears the previous
image. Account removal prevents further use of that sign-in's cached
data.

Profile, channel metadata, and membership use separate bounded
downloads, so an oversized roster cannot discard the smaller records.
Sender enrichment can finish while channel requests are pending. A
successful partial channel refresh atomically removes its old
counterpart, preventing a mixed group identity or recipient count.
Group-specific notification presentation still requires a verified
recipient count.

The
[specification](https://github.com/block/buzz/blob/native-notification-enrichment/docs/native-notification-enrichment.md)
describes the display, caching, and account-removal behavior. General
persistent image caching in Flutter remains separate.

### Validation

[Hosted CI](https://github.com/block/buzz/actions/runs/38780108487)
passed on the final commit, including all Flutter shards, Android, the
full iOS release and simulator builds, and native
controls/push-navigation tests. The [current-commit security
review](https://github.com/block/buzz/pull/8369#issuecomment-6197508385)
found no issues.

The native package passed 159 tests, including new coverage for cache
freshness, avatar replacement and removal, sign-in isolation, concurrent
storage, late results, single delivery, bounded downloads, and SVG
validation. A regression test exposed identical HTTP authentication
event IDs for requests in the same second; each request now includes a
unique nonce, matching the Dart and CLI clients.

Full local CI passed before that small authentication fix. The complete
native suite and signed device build passed again on the latest commit,
including response-size limits, overlapping avatar downloads, canonical
relay-origin matching, independent profile/channel refreshes,
account-key changes before and during requests, and a single initial
snapshot read across 64 accounts, signed IPv6 avatar authorities, and
partial channel refreshes. Focused Flutter regression tests passed 198
tests with three configuration-specific skips; seven avatar widget tests
also passed. Flutter analysis is clean, and the notification extension
builds for the simulator.

On an iPhone SE, the notification displayed the sender name, avatar,
channel, and message before the updated main app was opened. Repeat
notifications used a byte-identical private cache. Three notifications
delivered together displayed the avatar after the authentication fix.
The next notification also displayed correctly after terminating the
extension process. The existing generic-push design can select the same
newest unread message for several pushes; this change does not alter
message selection.

![Notification before first app launch after
upgrade](https://raw.githubusercontent.com/block/buzz/37d450855e026178836e2a9e05b0081ebbc3b479/pr-8369--before-first-app-launch.png)

Authenticated HTTP avatar fetching and persistent HTTP cache reuse also
passed on the SE. The first notification downloaded a relay thumbnail.
After terminating the extension and expiring its separate avatar entry,
the next notification displayed the avatar and refreshed that entry
without another media request in the relay logs. The main app remained
unopened throughout. The original app build and test profile were
restored afterward.

Automated tests cover slow and failed requests, stale images, removal,
and account revocation; those cases have not all been exercised on the
phone.

### Before merge

- [x] Verify authenticated HTTP avatar fetching and cache reuse on iOS
against a working relay media service.

<!-- buzz-review-completed -->

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-15 11:16:04 -07:00

798 lines
28 KiB
Swift

import CryptoKit
import Foundation
/// A verified sender profile retained for notification presentation.
public struct BuzzPushCachedProfile: Codable, Equatable, Sendable {
public let communityID: String
public let relayOrigin: String
public let pubkey: String
public let displayName: String?
public let pictureHash: String?
public let avatarPNG: Data?
public let eventID: String
public let eventCreatedAt: Int
public let cachedAt: Int
public init(
communityID: String,
relayOrigin: String,
pubkey: String,
displayName: String?,
pictureHash: String?,
avatarPNG: Data?,
eventID: String,
eventCreatedAt: Int,
cachedAt: Int
) {
self.communityID = communityID
self.relayOrigin = relayOrigin
self.pubkey = pubkey
self.displayName = displayName
self.pictureHash = pictureHash
self.avatarPNG = avatarPNG
self.eventID = eventID
self.eventCreatedAt = eventCreatedAt
self.cachedAt = cachedAt
}
}
/// Verified channel metadata retained for notification presentation.
public struct BuzzPushCachedChannel: Codable, Equatable, Sendable {
public let communityID: String
public let relayOrigin: String
public let channelID: String
public let relayMetadataPubkey: String
public let displayName: String?
/// Relay-verified Buzz channel type, when recognized.
public let channelType: String?
/// Exact unique-member count when bounded, or a value above the bound when oversized.
public let memberCount: Int?
/// Complete community-and-channel-scoped member digests, when within bounds.
public let memberDigests: [String]?
/// Event ID that established the cached membership snapshot.
public let membershipEventID: String?
/// Creation time of the cached membership replacement event.
public let membershipEventCreatedAt: Int?
/// Device time when the membership snapshot was cached.
public let membershipCachedAt: Int?
public let eventID: String
public let eventCreatedAt: Int
public let cachedAt: Int
public init(
communityID: String,
relayOrigin: String,
channelID: String,
relayMetadataPubkey: String,
displayName: String?,
channelType: String? = nil,
memberCount: Int? = nil,
memberDigests: [String]? = nil,
membershipEventID: String? = nil,
membershipEventCreatedAt: Int? = nil,
membershipCachedAt: Int? = nil,
eventID: String,
eventCreatedAt: Int,
cachedAt: Int
) {
self.communityID = communityID
self.relayOrigin = relayOrigin
self.channelID = channelID
self.relayMetadataPubkey = relayMetadataPubkey
self.displayName = displayName
self.channelType = channelType
self.memberCount = memberCount
self.memberDigests = memberDigests
self.membershipEventID = membershipEventID
self.membershipEventCreatedAt = membershipEventCreatedAt
self.membershipCachedAt = membershipCachedAt
self.eventID = eventID
self.eventCreatedAt = eventCreatedAt
self.cachedAt = cachedAt
}
}
/// Atomic App Group snapshot shared by the app and its notification extension.
public struct BuzzPushPresentationCacheSnapshot: Codable, Equatable, Sendable {
public static let currentVersion = 1
public let version: Int
public var communities: [PushLeaseCommunity]
public var profiles: [BuzzPushCachedProfile]
public var channels: [BuzzPushCachedChannel]
public init(
version: Int = currentVersion,
communities: [PushLeaseCommunity] = [],
profiles: [BuzzPushCachedProfile] = [],
channels: [BuzzPushCachedChannel] = []
) {
self.version = version
self.communities = communities
self.profiles = profiles
self.channels = channels
}
public static func decode(_ data: Data?) -> Self {
guard let data,
let snapshot = try? JSONDecoder().decode(Self.self, from: data),
snapshot.version == currentVersion
else { return Self() }
return snapshot
}
public func profile(
communityID: String,
relayOrigin: String,
pubkey: String
) -> BuzzPushCachedProfile? {
let normalizedPubkey = pubkey.lowercased()
return profiles.first {
$0.communityID == communityID && $0.relayOrigin == relayOrigin
&& $0.pubkey == normalizedPubkey
}
}
public func channel(
communityID: String,
relayOrigin: String,
channelID: String
) -> BuzzPushCachedChannel? {
channels.first {
$0.communityID == communityID && $0.relayOrigin == relayOrigin
&& $0.channelID == channelID
}
}
}
/// One app-provided profile update, optionally carrying a sanitized local thumbnail.
public struct BuzzPushProfileCacheUpdate: Sendable {
public let event: VerifiedNostrEvent
public let avatarPNG: Data?
public init(event: VerifiedNostrEvent, avatarPNG: Data? = nil) {
self.event = event
self.avatarPNG = avatarPNG
}
}
/// Maintains the bounded presentation snapshot. The app is the sole writer.
public final class BuzzPushPresentationCacheStore: @unchecked Sendable {
public static let fileName = "push-snapshot.json"
public static let freshnessLifetime: TimeInterval = 24 * 60 * 60
public static let maximumProfiles = 256
public static let maximumChannels = 512
public static let maximumCommunities = 64
public static let maximumMembersPerChannel = 512
public static let maximumTotalMemberDigests = 8_192
public static let maximumAvatarBytes = 64 * 1024
public static let maximumTotalAvatarBytes = 4 * 1024 * 1024
public static let maximumSnapshotBytes = 8 * 1024 * 1024
static let maximumProfileMetadataBytes = 256 * 1024
private let fileURL: URL
private let now: () -> Date
private let lock = NSLock()
public init(containerURL: URL, now: @escaping () -> Date = Date.init) {
fileURL = containerURL.appendingPathComponent(Self.fileName)
self.now = now
}
/// Replaces the app's flattened, relay-accepted community query policy.
public func replaceCommunities(_ communities: [PushLeaseCommunity]) throws {
guard communities.count <= Self.maximumCommunities else { return }
lock.lock()
defer { lock.unlock() }
var snapshot = loadLocked()
snapshot.communities = communities.map { community in
var community = community
let previous = snapshot.communities.first {
$0.id == community.id && $0.pubkey == community.pubkey
&& Self.canonicalRelayOrigin($0.relayUrl) == Self.canonicalRelayOrigin(community.relayUrl)
}
community.notificationSessionID = previous?.notificationSessionID ?? UUID().uuidString
return community
}
// Account configuration is the only app-owned notification data. Discard
// presentation exports left by older app versions, including retained accounts.
snapshot.profiles.removeAll()
snapshot.channels.removeAll()
try writeLocked(snapshot)
}
/// Saves verified kind-0 events and returns the event IDs still needing thumbnails.
@discardableResult
public func updateProfiles(
communityID: String,
relayOrigin: String,
updates: [BuzzPushProfileCacheUpdate]
) throws -> Set<String> {
guard Self.isBoundedOpaqueID(communityID),
let canonicalRelayOrigin = Self.canonicalRelayOrigin(relayOrigin),
updates.count <= Self.maximumProfiles
else { return [] }
lock.lock()
defer { lock.unlock() }
var snapshot = loadLocked()
let cachedAt = Int(now().timeIntervalSince1970)
var acceptedEventIDs = Set<String>()
for update in updates {
let event = update.event
guard event.kind == 0, event.hasValidIDAndSignature() else { continue }
let pubkey = event.pubkey.lowercased()
guard Self.isHexPubkey(pubkey) else { continue }
let metadata = Self.profileMetadata(event)
let index = snapshot.profiles.firstIndex {
$0.communityID == communityID && $0.relayOrigin == canonicalRelayOrigin
&& $0.pubkey == pubkey
}
let existing = index.map { snapshot.profiles[$0] }
guard
Self.shouldReplace(
existingCreatedAt: existing?.eventCreatedAt,
existingID: existing?.eventID,
candidateCreatedAt: event.createdAt,
candidateID: event.id
)
else { continue }
let suppliedAvatar = Self.normalizedAvatarPNG(update.avatarPNG)
let preservedAvatar =
existing?.pictureHash == metadata.pictureHash
? existing?.avatarPNG : nil
let entry = BuzzPushCachedProfile(
communityID: communityID,
relayOrigin: canonicalRelayOrigin,
pubkey: pubkey,
displayName: metadata.displayName,
pictureHash: metadata.pictureHash,
avatarPNG: metadata.pictureHash == nil ? nil : suppliedAvatar ?? preservedAvatar,
eventID: event.id,
eventCreatedAt: event.createdAt,
cachedAt: cachedAt
)
if let index {
snapshot.profiles[index] = entry
} else {
snapshot.profiles.append(entry)
}
acceptedEventIDs.insert(event.id)
}
Self.enforceBounds(&snapshot)
try writeLocked(snapshot)
return Set(
snapshot.profiles.compactMap { profile in
guard profile.communityID == communityID,
profile.relayOrigin == canonicalRelayOrigin,
acceptedEventIDs.contains(profile.eventID),
profile.pictureHash != nil,
profile.avatarPNG == nil
else { return nil }
return profile.eventID
})
}
/// Saves bounded relay-authorized kind-39000 metadata and kind-39002 membership snapshots.
public func updateChannels(
communityID: String,
relayOrigin: String,
relayMetadataPubkey: String,
metadataEvents: [VerifiedNostrEvent],
membershipEvents: [VerifiedNostrEvent]
) throws {
let normalizedRelayPubkey = relayMetadataPubkey.lowercased()
guard Self.isBoundedOpaqueID(communityID),
let canonicalRelayOrigin = Self.canonicalRelayOrigin(relayOrigin),
Self.isHexPubkey(normalizedRelayPubkey),
metadataEvents.count <= Self.maximumChannels,
membershipEvents.count <= Self.maximumChannels
else {
return
}
lock.lock()
defer { lock.unlock() }
var snapshot = loadLocked()
let cachedAt = Int(now().timeIntervalSince1970)
for event in metadataEvents {
guard event.kind == 39_000, event.hasValidIDAndSignature(),
event.pubkey.lowercased() == normalizedRelayPubkey,
let channelID = Self.tagValue("d", in: event),
Self.isBoundedOpaqueID(channelID)
else { continue }
let index = snapshot.channels.firstIndex {
$0.communityID == communityID && $0.relayOrigin == canonicalRelayOrigin
&& $0.channelID == channelID
}
let existing = index.map { snapshot.channels[$0] }
let hasCurrentAuthority = existing?.relayMetadataPubkey == normalizedRelayPubkey
guard
!hasCurrentAuthority
|| Self.shouldReplace(
existingCreatedAt: existing?.eventCreatedAt,
existingID: existing?.eventID,
candidateCreatedAt: event.createdAt,
candidateID: event.id
)
else { continue }
let entry = BuzzPushCachedChannel(
communityID: communityID,
relayOrigin: canonicalRelayOrigin,
channelID: channelID,
relayMetadataPubkey: normalizedRelayPubkey,
displayName: Self.normalizedDisplayName(Self.tagValue("name", in: event)),
channelType: Self.normalizedChannelType(Self.tagValue("t", in: event)),
memberCount: hasCurrentAuthority ? existing?.memberCount : nil,
memberDigests: hasCurrentAuthority ? existing?.memberDigests : nil,
membershipEventID: hasCurrentAuthority ? existing?.membershipEventID : nil,
membershipEventCreatedAt: hasCurrentAuthority
? existing?.membershipEventCreatedAt : nil,
membershipCachedAt: hasCurrentAuthority ? existing?.membershipCachedAt : nil,
eventID: event.id,
eventCreatedAt: event.createdAt,
cachedAt: cachedAt
)
if let index {
snapshot.channels[index] = entry
} else {
snapshot.channels.append(entry)
}
}
Self.enforceChannelCountBound(&snapshot)
for event in membershipEvents {
guard event.kind == 39_002, event.hasValidIDAndSignature(),
event.pubkey.lowercased() == normalizedRelayPubkey,
let channelID = Self.tagValue("d", in: event),
Self.isBoundedOpaqueID(channelID),
let membership = Self.normalizedChannelMembership(
event,
communityID: communityID,
channelID: channelID
),
let index = snapshot.channels.firstIndex(where: {
$0.communityID == communityID && $0.relayOrigin == canonicalRelayOrigin
&& $0.channelID == channelID
&& $0.relayMetadataPubkey == normalizedRelayPubkey
})
else { continue }
let existing = snapshot.channels[index]
guard
Self.shouldReplace(
existingCreatedAt: existing.membershipEventCreatedAt,
existingID: existing.membershipEventID,
candidateCreatedAt: event.createdAt,
candidateID: event.id
)
else { continue }
snapshot.channels[index] = BuzzPushCachedChannel(
communityID: existing.communityID,
relayOrigin: existing.relayOrigin,
channelID: existing.channelID,
relayMetadataPubkey: existing.relayMetadataPubkey,
displayName: existing.displayName,
channelType: existing.channelType,
memberCount: membership.count,
memberDigests: membership.digests,
membershipEventID: event.id,
membershipEventCreatedAt: event.createdAt,
membershipCachedAt: cachedAt,
eventID: existing.eventID,
eventCreatedAt: existing.eventCreatedAt,
cachedAt: existing.cachedAt
)
Self.enforceMemberDigestBound(&snapshot)
}
Self.enforceBounds(&snapshot)
try writeLocked(snapshot)
}
/// Attaches an app-rendered thumbnail to every verified profile with this source digest.
@discardableResult
public func updateAvatar(
communityID: String,
relayOrigin: String,
sourceURL: String,
avatarPNG: Data
) throws -> Bool {
guard Self.isBoundedOpaqueID(communityID),
let canonicalRelayOrigin = Self.canonicalRelayOrigin(relayOrigin),
let normalizedURL = Self.normalizedAvatarURL(sourceURL),
let normalizedPNG = Self.normalizedAvatarPNG(avatarPNG)
else { return false }
let pictureHash = VerifiedNostrEvent.hex(
SHA256.hash(data: Data(normalizedURL.utf8))
)
lock.lock()
defer { lock.unlock() }
var snapshot = loadLocked()
var changed = false
for index in snapshot.profiles.indices
where
snapshot.profiles[index].communityID == communityID
&& snapshot.profiles[index].relayOrigin == canonicalRelayOrigin
&& snapshot.profiles[index].pictureHash == pictureHash
&& snapshot.profiles[index].avatarPNG != normalizedPNG
{
let profile = snapshot.profiles[index]
snapshot.profiles[index] = BuzzPushCachedProfile(
communityID: profile.communityID,
relayOrigin: profile.relayOrigin,
pubkey: profile.pubkey,
displayName: profile.displayName,
pictureHash: profile.pictureHash,
avatarPNG: normalizedPNG,
eventID: profile.eventID,
eventCreatedAt: profile.eventCreatedAt,
cachedAt: profile.cachedAt
)
changed = true
}
guard changed else { return false }
Self.enforceBounds(&snapshot)
try writeLocked(snapshot)
return true
}
private func loadLocked() -> BuzzPushPresentationCacheSnapshot {
guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
let fileSize = values.fileSize,
fileSize <= Self.maximumSnapshotBytes
else { return BuzzPushPresentationCacheSnapshot() }
return BuzzPushPresentationCacheSnapshot.decode(try? Data(contentsOf: fileURL))
}
private func writeLocked(_ snapshot: BuzzPushPresentationCacheSnapshot) throws {
let data = try Self.encodedBoundedSnapshot(snapshot)
try data.write(to: fileURL, options: [.atomic])
#if os(iOS)
try FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: fileURL.path
)
#endif
}
static func encodedBoundedSnapshot(
_ snapshot: BuzzPushPresentationCacheSnapshot
) throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
var bounded = snapshot
Self.enforceBounds(&bounded)
var data = try encoder.encode(bounded)
if data.count > Self.maximumSnapshotBytes {
var estimatedExcess = data.count - Self.maximumSnapshotBytes + 1_024
for index in bounded.profiles.indices.reversed() {
guard let avatar = bounded.profiles[index].avatarPNG else { continue }
estimatedExcess -= min(estimatedExcess, 4 * ((avatar.count + 2) / 3))
bounded.profiles[index] = Self.removingAvatar(from: bounded.profiles[index])
if estimatedExcess == 0 { break }
}
data = try encoder.encode(bounded)
}
if data.count > Self.maximumSnapshotBytes {
for index in bounded.channels.indices.reversed() {
guard bounded.channels[index].memberDigests != nil else { continue }
bounded.channels[index] = Self.removingMemberDigests(from: bounded.channels[index])
data = try encoder.encode(bounded)
if data.count <= Self.maximumSnapshotBytes { break }
}
}
while data.count > Self.maximumSnapshotBytes,
!bounded.profiles.isEmpty || !bounded.channels.isEmpty
{
let entryCount = bounded.profiles.count + bounded.channels.count
let ratio = Double(Self.maximumSnapshotBytes) / Double(data.count)
let targetCount = max(0, min(entryCount - 1, Int(Double(entryCount) * ratio * 0.95)))
Self.removeOldestEntries(entryCount - targetCount, from: &bounded)
data = try encoder.encode(bounded)
}
return data
}
static func profileMetadata(
_ event: VerifiedNostrEvent
) -> (displayName: String?, pictureHash: String?) {
guard event.content.utf8.count <= maximumProfileMetadataBytes,
let data = event.content.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return (nil, nil) }
let displayName =
normalizedDisplayName(object["display_name"] as? String)
?? normalizedDisplayName(object["name"] as? String)
let pictureHash = normalizedAvatarURL(object["picture"] as? String).map {
VerifiedNostrEvent.hex(SHA256.hash(data: Data($0.utf8)))
}
return (displayName, pictureHash)
}
static func normalizedDisplayName(_ value: String?) -> String? {
guard let value else { return nil }
let collapsed = value.precomposedStringWithCanonicalMapping
.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: " ")
guard !collapsed.isEmpty else { return nil }
var bounded = String(collapsed.prefix(128))
while bounded.utf8.count > 512, !bounded.isEmpty {
bounded.removeLast()
}
return bounded.isEmpty ? nil : bounded
}
static func normalizedChannelType(_ value: String?) -> String? {
guard let value, ["stream", "forum", "dm"].contains(value) else { return nil }
return value
}
static func normalizedChannelMembership(
_ event: VerifiedNostrEvent,
communityID: String,
channelID: String
) -> (count: Int, digests: [String]?)? {
var pubkeys = Set<String>()
var exceededMemberBound = false
for tag in event.tags where tag.first == "p" {
guard tag.count >= 2 else { return nil }
let pubkey = tag[1].lowercased()
guard isHexPubkey(pubkey) else { return nil }
if !exceededMemberBound {
pubkeys.insert(pubkey)
exceededMemberBound = pubkeys.count > maximumMembersPerChannel
}
}
let digests =
exceededMemberBound
? nil
: pubkeys.map {
BuzzPushPresentationIdentity.channelMember(
communityID: communityID,
channelID: channelID,
pubkey: $0
)
}.sorted()
return (
exceededMemberBound ? maximumMembersPerChannel + 1 : pubkeys.count,
digests
)
}
static func normalizedAvatarURL(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("data:image/") {
guard trimmed.utf8.count <= maximumProfileMetadataBytes,
let separator = trimmed.firstIndex(of: ","),
separator < trimmed.index(before: trimmed.endIndex)
else { return nil }
let metadata = trimmed[..<separator].lowercased()
guard metadata.hasSuffix(";base64"),
["data:image/png;base64", "data:image/jpeg;base64", "data:image/webp;base64"]
.contains(String(metadata))
else { return nil }
return trimmed
}
guard trimmed.utf8.count <= 2_048,
let components = URLComponents(string: trimmed),
components.user == nil, components.password == nil,
components.host?.isEmpty == false,
["http", "https"].contains(components.scheme?.lowercased() ?? "")
else { return nil }
return components.url?.absoluteString
}
public static func canonicalRelayOrigin(_ value: String) -> String? {
guard value.utf8.count <= 2_048,
var components = URLComponents(string: value),
components.host?.isEmpty == false,
components.user == nil,
components.password == nil,
components.query == nil,
components.fragment == nil,
components.path.isEmpty || components.path == "/"
else { return nil }
switch components.scheme?.lowercased() {
case "wss": components.scheme = "https"
case "ws": components.scheme = "http"
case "https", "http": break
default: return nil
}
components.path = ""
guard let result = components.string, result.utf8.count <= 2_048 else { return nil }
return result
}
static func tagValue(_ name: String, in event: VerifiedNostrEvent) -> String? {
event.tags.first { $0.count >= 2 && $0[0] == name }?[1]
}
private static func isBoundedOpaqueID(_ value: String) -> Bool {
!value.isEmpty && value.utf8.count <= 1_024
}
private static func isHexPubkey(_ value: String) -> Bool {
value.count == 64 && VerifiedNostrEvent.hexBytes(value)?.count == 32
}
private static func normalizedAvatarPNG(_ data: Data?) -> Data? {
guard let data, !data.isEmpty, data.count <= maximumAvatarBytes,
data.starts(with: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
else { return nil }
return data
}
static func shouldReplace(
existingCreatedAt: Int?,
existingID: String?,
candidateCreatedAt: Int,
candidateID: String
) -> Bool {
guard let existingCreatedAt, let existingID else { return true }
return candidateCreatedAt > existingCreatedAt
|| (candidateCreatedAt == existingCreatedAt && candidateID <= existingID)
}
static func enforceBounds(_ snapshot: inout BuzzPushPresentationCacheSnapshot) {
snapshot.communities = Array(snapshot.communities.prefix(maximumCommunities))
snapshot.profiles = Array(
snapshot.profiles.sorted(by: profileNewestFirst).prefix(maximumProfiles)
)
enforceChannelCountBound(&snapshot)
enforceMemberDigestBound(&snapshot)
var avatarBytes = snapshot.profiles.reduce(0) { $0 + ($1.avatarPNG?.count ?? 0) }
guard avatarBytes > maximumTotalAvatarBytes else { return }
for index in snapshot.profiles.indices.reversed() {
guard let avatar = snapshot.profiles[index].avatarPNG else { continue }
avatarBytes -= avatar.count
let profile = snapshot.profiles[index]
snapshot.profiles[index] = removingAvatar(from: profile)
if avatarBytes <= maximumTotalAvatarBytes { break }
}
}
private static func enforceChannelCountBound(
_ snapshot: inout BuzzPushPresentationCacheSnapshot
) {
snapshot.channels = Array(
snapshot.channels.sorted(by: channelNewestFirst).prefix(maximumChannels)
)
}
private static func enforceMemberDigestBound(
_ snapshot: inout BuzzPushPresentationCacheSnapshot
) {
snapshot.channels.sort(by: channelNewestFirst)
var memberDigestCount = snapshot.channels.reduce(0) {
$0 + ($1.memberDigests?.count ?? 0)
}
guard memberDigestCount > maximumTotalMemberDigests else { return }
for index in snapshot.channels.indices.reversed() {
guard let memberDigests = snapshot.channels[index].memberDigests else { continue }
memberDigestCount -= memberDigests.count
snapshot.channels[index] = removingMemberDigests(from: snapshot.channels[index])
if memberDigestCount <= maximumTotalMemberDigests { break }
}
}
private static func removingAvatar(
from profile: BuzzPushCachedProfile
) -> BuzzPushCachedProfile {
BuzzPushCachedProfile(
communityID: profile.communityID,
relayOrigin: profile.relayOrigin,
pubkey: profile.pubkey,
displayName: profile.displayName,
pictureHash: profile.pictureHash,
avatarPNG: nil,
eventID: profile.eventID,
eventCreatedAt: profile.eventCreatedAt,
cachedAt: profile.cachedAt
)
}
private static func removingMemberDigests(
from channel: BuzzPushCachedChannel
) -> BuzzPushCachedChannel {
BuzzPushCachedChannel(
communityID: channel.communityID,
relayOrigin: channel.relayOrigin,
channelID: channel.channelID,
relayMetadataPubkey: channel.relayMetadataPubkey,
displayName: channel.displayName,
channelType: channel.channelType,
memberCount: channel.memberCount,
memberDigests: nil,
membershipEventID: channel.membershipEventID,
membershipEventCreatedAt: channel.membershipEventCreatedAt,
membershipCachedAt: channel.membershipCachedAt,
eventID: channel.eventID,
eventCreatedAt: channel.eventCreatedAt,
cachedAt: channel.cachedAt
)
}
private static func removeOldestEntries(
_ count: Int,
from snapshot: inout BuzzPushPresentationCacheSnapshot
) {
for _ in 0..<count {
switch (snapshot.profiles.last, snapshot.channels.last) {
case (nil, nil): return
case (.some, nil): snapshot.profiles.removeLast()
case (nil, .some): snapshot.channels.removeLast()
case (let profile?, let channel?):
let channelCachedAt = channelLastCachedAt(channel)
if profile.cachedAt < channelCachedAt
|| (profile.cachedAt == channelCachedAt && profile.eventID <= channel.eventID)
{
snapshot.profiles.removeLast()
} else {
snapshot.channels.removeLast()
}
}
}
}
private static func profileNewestFirst(
_ lhs: BuzzPushCachedProfile,
_ rhs: BuzzPushCachedProfile
) -> Bool {
lhs.cachedAt == rhs.cachedAt ? lhs.eventID > rhs.eventID : lhs.cachedAt > rhs.cachedAt
}
private static func channelNewestFirst(
_ lhs: BuzzPushCachedChannel,
_ rhs: BuzzPushCachedChannel
) -> Bool {
let lhsCachedAt = channelLastCachedAt(lhs)
let rhsCachedAt = channelLastCachedAt(rhs)
return lhsCachedAt == rhsCachedAt ? lhs.eventID > rhs.eventID : lhsCachedAt > rhsCachedAt
}
private static func channelLastCachedAt(_ channel: BuzzPushCachedChannel) -> Int {
max(channel.cachedAt, channel.membershipCachedAt ?? 0)
}
}
/// Stable, privacy-preserving identifiers used only after the NSE resolves an event.
public enum BuzzPushPresentationIdentity {
public static func conversation(communityID: String, channelID: String) -> String {
scoped(namespace: "conversation", values: [communityID, channelID])
}
public static func sender(communityID: String, pubkey: String) -> String {
scoped(namespace: "sender", values: [communityID, pubkey.lowercased()])
}
/// Returns a stable, channel-scoped digest used for exact local membership checks.
public static func channelMember(
communityID: String,
channelID: String,
pubkey: String
) -> String {
scoped(
namespace: "channel-member",
values: [communityID, channelID, pubkey.lowercased()]
)
}
private static func scoped(namespace: String, values: [String]) -> String {
let encoded = (try? JSONEncoder().encode([namespace] + values)) ?? Data()
return "buzz.\(namespace).\(VerifiedNostrEvent.hex(SHA256.hash(data: encoded)))"
}
}