mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
fix(ios): stop repeating an old message in push notifications (#8973)
## Context Pushes from the gateway carry no event reference (`crates/buzz-push-gateway/src/model.rs`), so the iOS Notification Service Extension resolves each push by querying the user's NIP-PL lease filters and showing the newest matching message. Any push whose trigger wasn't the newest message (a reaction, an edit or deletion, a community query that failed, or several pushes arriving for one message) showed that same newest message again. Tom saw one message notify four times over three hours. ## Behavior - The extension now remembers which messages it has shown, per community and account, in its existing notification cache (`BuzzNotificationCache`). - Each push shows the newest message not yet shown, across all communities. If every candidate was already shown, it shows the generic "You have a new message." text. - A message more than 15 minutes older than the newest one already shown, in any configured community (including one whose query failed), is treated as already covered. So a stale backlog item can't surface hours later, even from a community where nothing has been shown yet. 15 minutes matches the relay's `created_at` drift bound (`MAX_TIMESTAMP_DRIFT_SECS`), so a sender with a slow clock still gets through. A future-dated `created_at` is capped at the time it was shown, so it can't hold back later messages. - Selection and claim happen in one locked cache call. It reads each community's store once and writes only when it records a claim, so concurrent pushes can't claim against a stale view, and a rejected candidate never rewrites the store. - Retention drops only entries the age check would reject anyway. The rest are capped at the union of the newest 256 by capped time and the newest 256 by `created_at`. So neither a burst of messages nor future-dated entries can evict a message the relay still returns. The field is optional, so existing caches still decode with no version bump. - Only the newest 10 matches per community are fetched, as before. If more than 10 messages arrive before their pushes resolve, the extra pushes show the generic text. - The claim happens before presentation. If presentation then fails, that push shows the generic text and the message isn't offered again, which is better than repeating. - If the cache can't be read, dedupe is skipped (fail open), so new messages are never hidden. If a claim can't be written, the new message is still shown. An already-shown message is still rejected, since rejecting needs no write. A revoked scope is skipped. - Dedupe state lives in the extension's `Caches` directory, which iOS can purge. After a purge, the next push can show the newest message again, once. This is the NSE-only fix. It doesn't change the gateway payload or NIP-PL. ## Validation - New test `testEachPushShowsANewMessageAndNeverRepeatsOne` uses a fresh cache instance per push, to model separate extension processes. It covers: a repeat push giving the generic text; out-of-order resolution (late, then early, then nothing); a stale message outside the window; and the future-date clamp. I checked by mutation that removing any one of the three guards (id check, window, clamp) fails it. - New test `testShownMessagesStayShownAcrossCommunitiesFailuresAndBursts` covers the review findings. It checks that: - an old message isn't shown from a community with nothing shown yet, including when the other community's query fails; - a burst of 100 newer messages doesn't evict a shown mention the relay still returns; - a read-only store neither repeats a shown message nor hides a new one; - a future-dated message isn't shown again after later messages. - New test `testFutureDatedEntriesDoNotEvictAClaimedMessage`: 257 future-dated claims don't evict a just-claimed message. - Mutation checks all fail these tests: a candidate-only watermark, retention by `created_at` only, a 64-entry cap, and pruning by capped time. - Full BuzzPushKit suite on `15913d0`: 106 XCTest and 65 Swift Testing tests passed. - Live relay run (on `baac384`): a scratch test drove the real resolver and cache against a local relay. One key posted messages and another resolved five pushes (not committed): | Push | `main` | This branch | | --- | --- | --- | | 1 (after "Hello 1") | Hello 1 | Hello 1 | | 2 (no new message) | Hello 1 | You have a new message. | | 3 (after "Hello 2", "Hello 3") | Hello 3 | Hello 3 | | 4 (no new message) | Hello 3 | Hello 2 | | 5 (no new message) | Hello 3 | You have a new message. | - Automated app check: built and launched the branch on an iOS 27 simulator. The app started and showed the signed-in home screen. Only the extension uses the changed types. - An independent fresh-context review found no blockers. Its comment suggestions were applied. ### Limitations I couldn't run a real APNs push end to end. Dev-signed builds get no gateway pushes, and `simctl push` on iOS 27 doesn't invoke the Notification Service Extension. The live relay run covers the resolver and cache logic the extension runs. 🤖 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@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -81,8 +81,31 @@ public final class BuzzNotificationCache: @unchecked Sendable {
|
||||
var metadata: [BuzzNotificationMetadata] = []
|
||||
var avatars: [BuzzNotificationAvatar] = []
|
||||
var avatarRequests: [String: String]?
|
||||
/// Messages already shown in a notification, newest first. Optional so stores
|
||||
/// written before it existed still decode.
|
||||
var presented: [PresentedMessage]?
|
||||
}
|
||||
|
||||
private struct PresentedMessage: Codable {
|
||||
let id: String
|
||||
/// The event's own `created_at`. Retention follows it, so an entry stays while the
|
||||
/// relay still ranks the event among the newest results.
|
||||
let createdAt: Int
|
||||
/// `createdAt` capped at the time it was shown, so a future-dated event can't hold
|
||||
/// back later messages.
|
||||
let watermark: Int
|
||||
}
|
||||
|
||||
/// How far before the newest shown message a message may be and still be shown.
|
||||
/// Pushes can resolve out of order, and the relay accepts a `created_at` up to 15
|
||||
/// minutes off (`MAX_TIMESTAMP_DRIFT_SECS` in the relay's ingest handler), so a new
|
||||
/// message can carry an earlier time than one already shown. Anything older is
|
||||
/// history, not the message a push is for.
|
||||
public static let presentationReorderWindow = 15 * 60
|
||||
/// Retention keeps up to this many entries by `watermark` and as many by `createdAt`,
|
||||
/// so neither future-dated entries nor a burst of recent ones can evict the other.
|
||||
static let maximumPresentedMessages = 256
|
||||
|
||||
public init(
|
||||
directory: URL,
|
||||
now: @escaping () -> Date = Date.init,
|
||||
@@ -253,6 +276,61 @@ public final class BuzzNotificationCache: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Claims the first of `candidates`, newest first, that hasn't been shown and isn't
|
||||
/// older than the newest message shown in any of `scopes` by more than
|
||||
/// ``presentationReorderWindow``, and returns its index. A push carries no event
|
||||
/// reference, so without this every push whose own event isn't the newest match would
|
||||
/// repeat the same message. One lock covers the whole selection, so concurrent pushes
|
||||
/// can't claim against a stale view, and only a claim writes. A revoked scope is
|
||||
/// skipped. Broken storage must not hide messages, so a candidate whose store can't
|
||||
/// be read or written is returned without being recorded.
|
||||
public func claimFirstUnpresented(
|
||||
_ candidates: [(event: VerifiedNostrEvent, scope: BuzzNotificationCacheScope)],
|
||||
scopes: [BuzzNotificationCacheScope]
|
||||
) throws -> Int? {
|
||||
try locked {
|
||||
var shown: [String: Result<[PresentedMessage], Error>] = [:]
|
||||
func presented(_ scope: BuzzNotificationCacheScope) -> Result<[PresentedMessage], Error> {
|
||||
if let cached = shown[scope.directoryName] { return cached }
|
||||
let result = Result { try unlockedAccess(scope) { $0.presented ?? [] } }
|
||||
shown[scope.directoryName] = result
|
||||
return result
|
||||
}
|
||||
let newestShown = (scopes + candidates.map(\.scope))
|
||||
.compactMap { try? presented($0).get().map(\.watermark).max() }.max()
|
||||
let now = Int(now().timeIntervalSince1970)
|
||||
for (index, candidate) in candidates.enumerated() {
|
||||
let watermark = min(candidate.event.createdAt, now)
|
||||
switch presented(candidate.scope) {
|
||||
case .failure(BuzzNotificationCacheError.revoked):
|
||||
continue
|
||||
case .failure(let error):
|
||||
NSLog("Notification dedupe unavailable: %@", String(describing: error))
|
||||
return index
|
||||
case .success(let entries):
|
||||
guard !entries.contains(where: { $0.id == candidate.event.id }) else { continue }
|
||||
if let newestShown, watermark < newestShown - Self.presentationReorderWindow {
|
||||
continue
|
||||
}
|
||||
}
|
||||
do {
|
||||
try unlockedAccess(candidate.scope, write: true) { state in
|
||||
state.presented =
|
||||
[PresentedMessage(
|
||||
id: candidate.event.id, createdAt: candidate.event.createdAt,
|
||||
watermark: watermark)] + (state.presented ?? [])
|
||||
}
|
||||
} catch BuzzNotificationCacheError.revoked {
|
||||
continue
|
||||
} catch {
|
||||
NSLog("Notification dedupe not recorded: %@", String(describing: error))
|
||||
}
|
||||
return index
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes abandoned sign-ins on the extension's next execution.
|
||||
public func removeRevokedScopes(keeping scopes: [BuzzNotificationCacheScope]) throws {
|
||||
try locked {
|
||||
@@ -291,53 +369,74 @@ public final class BuzzNotificationCache: @unchecked Sendable {
|
||||
_ scope: BuzzNotificationCacheScope, write: Bool = false,
|
||||
_ operation: (inout State) throws -> T
|
||||
) throws -> T {
|
||||
try locked {
|
||||
guard isCurrent(scope) else { throw BuzzNotificationCacheError.revoked }
|
||||
let directory = root.appendingPathComponent(scope.directoryName, isDirectory: true)
|
||||
let file = directory.appendingPathComponent("metadata.json")
|
||||
var state = State()
|
||||
if FileManager.default.fileExists(atPath: file.path) {
|
||||
let attributes = try file.resourceValues(forKeys: [.fileSizeKey])
|
||||
guard let size = attributes.fileSize, size <= Self.maximumBytes else {
|
||||
try FileManager.default.removeItem(at: file)
|
||||
throw BuzzNotificationCacheError.corruptStore
|
||||
}
|
||||
do {
|
||||
state = try JSONDecoder().decode(State.self, from: Data(contentsOf: file))
|
||||
} catch {
|
||||
try FileManager.default.removeItem(at: file)
|
||||
throw BuzzNotificationCacheError.corruptStore
|
||||
}
|
||||
guard state.version == 1 else {
|
||||
try FileManager.default.removeItem(at: file)
|
||||
throw BuzzNotificationCacheError.corruptStore
|
||||
}
|
||||
try locked { try unlockedAccess(scope, write: write, operation) }
|
||||
}
|
||||
|
||||
/// ``access(_:write:_:)`` for a caller that already holds the lock.
|
||||
private func unlockedAccess<T>(
|
||||
_ scope: BuzzNotificationCacheScope, write: Bool = false,
|
||||
_ operation: (inout State) throws -> T
|
||||
) throws -> T {
|
||||
guard isCurrent(scope) else { throw BuzzNotificationCacheError.revoked }
|
||||
let directory = root.appendingPathComponent(scope.directoryName, isDirectory: true)
|
||||
let file = directory.appendingPathComponent("metadata.json")
|
||||
var state = State()
|
||||
if FileManager.default.fileExists(atPath: file.path) {
|
||||
let attributes = try file.resourceValues(forKeys: [.fileSizeKey])
|
||||
guard let size = attributes.fileSize, size <= Self.maximumBytes else {
|
||||
try FileManager.default.removeItem(at: file)
|
||||
throw BuzzNotificationCacheError.corruptStore
|
||||
}
|
||||
let result = try operation(&state)
|
||||
guard isCurrent(scope) else { throw BuzzNotificationCacheError.revoked }
|
||||
if write {
|
||||
state.metadata = Array(state.metadata.sorted { $0.checkedAt > $1.checkedAt }.prefix(512))
|
||||
state.avatars = Array(state.avatars.sorted { $0.savedAt > $1.savedAt }.prefix(128))
|
||||
let profileAuthors = Set(
|
||||
state.metadata.filter { $0.event.kind == 0 }.map { $0.event.pubkey })
|
||||
state.avatarRequests = state.avatarRequests?.filter { profileAuthors.contains($0.key) }
|
||||
let encoder = JSONEncoder()
|
||||
var data = try encoder.encode(state)
|
||||
while data.count > Self.maximumBytes {
|
||||
if !state.avatars.isEmpty {
|
||||
state.avatars.removeLast()
|
||||
} else if !state.metadata.isEmpty {
|
||||
state.metadata.removeLast()
|
||||
} else {
|
||||
throw BuzzNotificationCacheError.oversizedRecord
|
||||
}
|
||||
data = try encoder.encode(state)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: file, options: .atomic)
|
||||
do {
|
||||
state = try JSONDecoder().decode(State.self, from: Data(contentsOf: file))
|
||||
} catch {
|
||||
try FileManager.default.removeItem(at: file)
|
||||
throw BuzzNotificationCacheError.corruptStore
|
||||
}
|
||||
guard state.version == 1 else {
|
||||
try FileManager.default.removeItem(at: file)
|
||||
throw BuzzNotificationCacheError.corruptStore
|
||||
}
|
||||
return result
|
||||
}
|
||||
let result = try operation(&state)
|
||||
guard isCurrent(scope) else { throw BuzzNotificationCacheError.revoked }
|
||||
if write {
|
||||
state.metadata = Array(state.metadata.sorted { $0.checkedAt > $1.checkedAt }.prefix(512))
|
||||
state.avatars = Array(state.avatars.sorted { $0.savedAt > $1.savedAt }.prefix(128))
|
||||
let profileAuthors = Set(
|
||||
state.metadata.filter { $0.event.kind == 0 }.map { $0.event.pubkey })
|
||||
state.avatarRequests = state.avatarRequests?.filter { profileAuthors.contains($0.key) }
|
||||
if let presented = state.presented, let newest = presented.map(\.watermark).max() {
|
||||
// The age check rejects anything older than the window, so those entries can
|
||||
// go. A future-dated entry stays while the relay may still rank it first.
|
||||
let cutoff = newest - Self.presentationReorderWindow
|
||||
// Entries are newest claim first, so ties keep the most recent claims.
|
||||
let current = Array(presented.filter { $0.createdAt >= cutoff }.enumerated())
|
||||
func newestIDs(by key: (PresentedMessage) -> Int) -> [String] {
|
||||
current.sorted {
|
||||
key($0.element) == key($1.element)
|
||||
? $0.offset < $1.offset : key($0.element) > key($1.element)
|
||||
}.prefix(Self.maximumPresentedMessages).map(\.element.id)
|
||||
}
|
||||
let retained = Set(newestIDs(by: \.watermark)).union(newestIDs(by: \.createdAt))
|
||||
state.presented = current.map(\.element).filter { retained.contains($0.id) }
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
var data = try encoder.encode(state)
|
||||
while data.count > Self.maximumBytes {
|
||||
if !state.avatars.isEmpty {
|
||||
state.avatars.removeLast()
|
||||
} else if !state.metadata.isEmpty {
|
||||
state.metadata.removeLast()
|
||||
} else {
|
||||
throw BuzzNotificationCacheError.oversizedRecord
|
||||
}
|
||||
data = try encoder.encode(state)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: file, options: .atomic)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func locked<T>(_ operation: () throws -> T) throws -> T {
|
||||
|
||||
@@ -54,7 +54,7 @@ public protocol BuzzPushNotificationResolving {
|
||||
func cancel()
|
||||
}
|
||||
|
||||
/// Reads configured Buzz communities and resolves their newest unread event.
|
||||
/// Reads configured Buzz communities and resolves their newest message not yet shown.
|
||||
public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
||||
// Each independent refresh has a hard transfer limit, allowing bounded
|
||||
// content plus JSON escaping, tags, and envelopes.
|
||||
@@ -108,20 +108,18 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
||||
var candidates: [(VerifiedNostrEvent, PushLeaseCommunity)] = []
|
||||
for community in communities {
|
||||
group.enter()
|
||||
query(community) { candidate in
|
||||
if let candidate {
|
||||
lock.lock()
|
||||
candidates.append(candidate)
|
||||
lock.unlock()
|
||||
}
|
||||
query(community) { messages in
|
||||
lock.lock()
|
||||
candidates += messages.map { ($0, community) }
|
||||
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
|
||||
let newestFirst = candidates.sorted {
|
||||
$0.0.createdAt == $1.0.createdAt ? $0.0.id < $1.0.id : $0.0.createdAt > $1.0.createdAt
|
||||
}
|
||||
guard let newest else {
|
||||
guard let newest = self.firstUnpresented(newestFirst, communities: communities) else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
@@ -129,6 +127,33 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
||||
}
|
||||
}
|
||||
|
||||
/// The push doesn't say which event it is for, so take the newest message not yet
|
||||
/// shown, in any community. Without extension storage, fall back to the newest message.
|
||||
/// The claim is made before presentation: if presenting then fails or expires, that
|
||||
/// push shows the generic text and the message isn't offered again, which is better
|
||||
/// than repeating a message in later notifications.
|
||||
private func firstUnpresented(
|
||||
_ newestFirst: [(VerifiedNostrEvent, PushLeaseCommunity)],
|
||||
communities: [PushLeaseCommunity]
|
||||
) -> (VerifiedNostrEvent, PushLeaseCommunity)? {
|
||||
guard let notificationCache else { return newestFirst.first }
|
||||
let candidates = newestFirst.compactMap { event, community in
|
||||
BuzzNotificationCacheScope(community: community).map { (candidate: (event, community), scope: $0) }
|
||||
}
|
||||
do {
|
||||
// Every community's history bounds the others, even one whose query failed, so a
|
||||
// community with nothing shown yet can't surface its old messages.
|
||||
return try notificationCache.claimFirstUnpresented(
|
||||
candidates.map { (event: $0.candidate.0, scope: $0.scope) },
|
||||
scopes: communities.compactMap(BuzzNotificationCacheScope.init(community:))
|
||||
).map { candidates[$0].candidate }
|
||||
} catch {
|
||||
// Unusable storage must not hide new messages; it can only fail to dedupe them.
|
||||
NSLog("Notification dedupe unavailable: %@", String(describing: error))
|
||||
return candidates.first?.candidate
|
||||
}
|
||||
}
|
||||
|
||||
private func matchingPrivateKey(for community: PushLeaseCommunity) -> String? {
|
||||
guard let scope = BuzzNotificationCacheScope(community: community),
|
||||
loadCommunities().contains(where: { BuzzNotificationCacheScope(community: $0) == scope })
|
||||
@@ -147,10 +172,10 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
||||
|
||||
private func query(
|
||||
_ community: PushLeaseCommunity,
|
||||
completion: @escaping ((VerifiedNostrEvent, PushLeaseCommunity)?) -> Void
|
||||
completion: @escaping ([VerifiedNostrEvent]) -> Void
|
||||
) {
|
||||
guard let privateKey = accountPrivateKey(for: community), community.pubkey?.isEmpty == false else {
|
||||
completion(nil)
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
guard
|
||||
@@ -161,7 +186,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
||||
withJSONObject: community.policies.map { $0.filter.queryFilter(since: nil, limit: 10) }
|
||||
)
|
||||
else {
|
||||
completion(nil)
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
send(
|
||||
@@ -172,19 +197,19 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
||||
let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode),
|
||||
let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
|
||||
else {
|
||||
completion(nil)
|
||||
completion([])
|
||||
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) })
|
||||
completion(
|
||||
Self.messages(
|
||||
events: events.filter { event in
|
||||
event.hasValidIDAndSignature()
|
||||
&& community.policies.contains { policy in
|
||||
PushLeaseMatcher.matches(event: event, policy: policy)
|
||||
}
|
||||
},
|
||||
community: community
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,12 +407,20 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
||||
events: [VerifiedNostrEvent],
|
||||
community: PushLeaseCommunity
|
||||
) -> VerifiedNostrEvent? {
|
||||
guard let mine = community.pubkey?.lowercased() else { return nil }
|
||||
messages(events: events, community: community).first
|
||||
}
|
||||
|
||||
/// Messages from others that a notification can show, newest first.
|
||||
private static func messages(
|
||||
events: [VerifiedNostrEvent],
|
||||
community: PushLeaseCommunity
|
||||
) -> [VerifiedNostrEvent] {
|
||||
guard let mine = community.pubkey?.lowercased() else { return [] }
|
||||
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(
|
||||
|
||||
@@ -386,6 +386,155 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
/// A push carries no event reference. Each push must show a message not shown before,
|
||||
/// including after the extension process restarts, instead of the newest match again.
|
||||
func testEachPushShowsANewMessageAndNeverRepeatsOne() throws {
|
||||
let snapshot = try snapshotData([community()])
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
addTeardownBlock { try? FileManager.default.removeItem(at: root) }
|
||||
let scope = try XCTUnwrap(BuzzNotificationCacheScope(community: community()))
|
||||
let now = Date(timeIntervalSince1970: TimeInterval(Self.now))
|
||||
// A fresh cache instance per push models a fresh extension process.
|
||||
func push() -> String? {
|
||||
resolve(makeResolver(communitiesData: snapshot,
|
||||
cacheOverride: BuzzNotificationCache(directory: root, now: { now }, isCurrent: { $0 == scope }),
|
||||
now: now))?.body
|
||||
}
|
||||
var relayEvents: [VerifiedNostrEvent] = []
|
||||
URLProtocolStub.handler = { request in
|
||||
Self.response(request, status: 200, data: try JSONEncoder().encode(relayEvents))
|
||||
}
|
||||
func message(_ content: String, secondsAgo: Int) throws -> VerifiedNostrEvent {
|
||||
try Self.signedEvent(privateKey: Self.profilePrivateKey, createdAt: Self.now - secondsAgo,
|
||||
kind: 9, tags: [["h", Self.channelID]], content: content)
|
||||
}
|
||||
let window = BuzzNotificationCache.presentationReorderWindow
|
||||
|
||||
let history = try message("Last week", secondsAgo: 7 * 24 * 3600)
|
||||
let first = try message("First", secondsAgo: 3600)
|
||||
relayEvents = [history, first]
|
||||
XCTAssertEqual(push(), "First", "with nothing shown yet, the newest message")
|
||||
XCTAssertNil(push(), "a push for a non-message event must not repeat it")
|
||||
|
||||
// Two messages whose pushes resolve out of order: the later push wins the first
|
||||
// resolution, so the earlier push must still find its own message.
|
||||
let early = try message("Early", secondsAgo: 60)
|
||||
let late = try message("Late", secondsAgo: 59)
|
||||
relayEvents = [late, early, first, history]
|
||||
XCTAssertEqual(push(), "Late")
|
||||
XCTAssertEqual(push(), "Early")
|
||||
XCTAssertNil(push())
|
||||
|
||||
// Older history is never surfaced by a later push.
|
||||
let stale = try message("Stale", secondsAgo: 59 + window + 1)
|
||||
relayEvents = [late, early, stale, first, history]
|
||||
XCTAssertNil(push(), "a message older than the reorder window is history")
|
||||
|
||||
// A future-dated message is shown once and doesn't hide messages that follow it.
|
||||
let future = try message("Future", secondsAgo: -3600)
|
||||
relayEvents = [future, late]
|
||||
XCTAssertEqual(push(), "Future")
|
||||
let next = try message("Next", secondsAgo: 0)
|
||||
relayEvents = [future, next, late]
|
||||
XCTAssertEqual(push(), "Next")
|
||||
XCTAssertNil(push())
|
||||
}
|
||||
|
||||
func testShownMessagesStayShownAcrossCommunitiesFailuresAndBursts() throws {
|
||||
let a = community(id: "community-a", relayUrl: "https://a.example")
|
||||
let b = community(id: "community-b", relayUrl: "https://b.example")
|
||||
let snapshot = try snapshotData([a, b])
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
addTeardownBlock { try? FileManager.default.removeItem(at: root) }
|
||||
let scopes = [a, b].compactMap { BuzzNotificationCacheScope(community: $0) }
|
||||
var clock = Self.now
|
||||
func push() -> String? {
|
||||
let now = Date(timeIntervalSince1970: TimeInterval(clock))
|
||||
return resolve(makeResolver(communitiesData: snapshot,
|
||||
privateKeys: ["community-a": Self.privateKey, "community-b": Self.privateKey],
|
||||
cacheOverride: BuzzNotificationCache(
|
||||
directory: root, now: { now }, isCurrent: { scopes.contains($0) }),
|
||||
now: now))?.body
|
||||
}
|
||||
var relayEvents: [String: [VerifiedNostrEvent]] = [:]
|
||||
var failingHosts: Set<String> = []
|
||||
URLProtocolStub.handler = { request in
|
||||
let host = request.url?.host ?? ""
|
||||
return Self.response(request, status: failingHosts.contains(host) ? 500 : 200,
|
||||
data: try JSONEncoder().encode(relayEvents[host] ?? []))
|
||||
}
|
||||
func message(_ content: String, createdAt: Int) throws -> VerifiedNostrEvent {
|
||||
try Self.signedEvent(privateKey: Self.profilePrivateKey, createdAt: createdAt,
|
||||
kind: 9, tags: [["h", Self.channelID]], content: content)
|
||||
}
|
||||
|
||||
// B has shown nothing yet, but A's shown message still marks B's last week as
|
||||
// history, even when A's query fails.
|
||||
let mention = try message("A mention", createdAt: clock - 60)
|
||||
relayEvents["a.example"] = [mention]
|
||||
relayEvents["b.example"] = [try message("B last week", createdAt: clock - 7 * 24 * 3600)]
|
||||
XCTAssertEqual(push(), "A mention")
|
||||
XCTAssertNil(push(), "an old message in another community is not new")
|
||||
failingHosts = ["a.example"]
|
||||
XCTAssertNil(push(), "a failed query doesn't drop its community's history")
|
||||
failingHosts = []
|
||||
|
||||
// A burst of newer messages doesn't evict one the relay still returns.
|
||||
var newest = mention
|
||||
for index in 0..<100 {
|
||||
clock += 1
|
||||
newest = try message("Burst \(index)", createdAt: clock)
|
||||
relayEvents["a.example"] = [newest, mention]
|
||||
XCTAssertEqual(push(), "Burst \(index)")
|
||||
}
|
||||
XCTAssertNil(push(), "a shown message is not shown again after a burst")
|
||||
|
||||
// A store that can't be written still remembers what it already shows.
|
||||
let directory = root.appendingPathComponent(scopes[0].directoryName, isDirectory: true)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: directory.path)
|
||||
addTeardownBlock {
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory.path)
|
||||
}
|
||||
XCTAssertNil(push(), "a read-only store does not repeat a shown message")
|
||||
relayEvents["a.example"] = [try message("Unrecorded", createdAt: clock), newest, mention]
|
||||
XCTAssertEqual(push(), "Unrecorded", "an unwritable store never hides a new message")
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory.path)
|
||||
|
||||
// A future-dated message stays remembered while the relay keeps returning it first,
|
||||
// even after later messages push its capped time out of the window.
|
||||
let future = try message("Future", createdAt: clock + 3600)
|
||||
relayEvents["a.example"] = [future]
|
||||
XCTAssertEqual(push(), "Future")
|
||||
for index in 0..<40 {
|
||||
clock += 30
|
||||
relayEvents["a.example"] = [future, try message("Later \(index)", createdAt: clock)]
|
||||
XCTAssertEqual(push(), "Later \(index)")
|
||||
}
|
||||
XCTAssertNil(push(), "a future-dated message is not shown again")
|
||||
}
|
||||
|
||||
func testFutureDatedEntriesDoNotEvictAClaimedMessage() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
addTeardownBlock { try? FileManager.default.removeItem(at: root) }
|
||||
let scope = try XCTUnwrap(BuzzNotificationCacheScope(community: community()))
|
||||
let cache = BuzzNotificationCache(
|
||||
directory: root, now: { Date(timeIntervalSince1970: TimeInterval(Self.now)) },
|
||||
isCurrent: { $0 == scope })
|
||||
func claim(_ event: VerifiedNostrEvent) throws -> Bool {
|
||||
try cache.claimFirstUnpresented([(event: event, scope: scope)], scopes: [scope]) == 0
|
||||
}
|
||||
for index in 0..<(BuzzNotificationCache.maximumPresentedMessages + 1) {
|
||||
let ahead = try Self.signedEvent(privateKey: Self.profilePrivateKey,
|
||||
createdAt: Self.now + 600 + index, kind: 9, tags: [["h", Self.channelID]],
|
||||
content: "Ahead \(index)")
|
||||
XCTAssertTrue(try claim(ahead))
|
||||
}
|
||||
let current = try Self.signedEvent(privateKey: Self.profilePrivateKey, createdAt: Self.now,
|
||||
kind: 9, tags: [["h", Self.channelID]], content: "Current")
|
||||
XCTAssertTrue(try claim(current))
|
||||
XCTAssertFalse(try claim(current), "a claimed message survives retention")
|
||||
}
|
||||
|
||||
func testFreshVerifiedCacheResolvesSenderAvatarAndChannelWithoutRefresh() throws {
|
||||
let message = try Self.signedEvent(
|
||||
privateKey: Self.profilePrivateKey,
|
||||
|
||||
Reference in New Issue
Block a user