Files
buzz/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift
Tom Brow 8222e99915 fix(mobile): keep thread references consistent across push and Inbox (#8332)
Follow-up to [#8294](https://github.com/block/buzz/pull/8294). Malformed
trailing thread tags could make a notification open the right thread
while Flutter indexed its reply elsewhere; uppercase references could
similarly miss lowercase event IDs.

Share one validated thread-reference parser between the mobile timeline,
Inbox, and feed helper. It ignores malformed markers, keeps the last
valid root/reply, and normalizes lookup IDs to lowercase. Native push
parsing now uses the same casing. Existing thread fixtures use valid
event IDs so they continue exercising real thread relationships.

Add RunnerTests coverage for the production native-to-Flutter argument
conversion, including buffered cold-start navigation and omitted
optional thread IDs, and run it in the native CI lane.

### Validation

- New parser and Inbox regressions cover malformed markers and uppercase
references. Widget regressions open and highlight the exact reply for
valid, uppercase, and trailing-malformed tags.
- After integrating [#8106](https://github.com/block/buzz/pull/8106),
Flutter analysis and the complete mobile suite pass: 3,337 tests, four
existing skips. Integration preserves per-event read predicates and
migrates incoming thread fixtures to valid IDs. An unchanged
keyboard-history test also failed with main's original parser; recording
reads before the scroll transition fixes its timing assumption while
retaining visibility assertions.
- Native package tests passed (127 tests), and both Runner bridge tests
passed on an iOS simulator with Xcode 26.5. Removing the production
`threadRootId` assignment made the bridge regression fail; restoring it
passed. Native code is unchanged by the main integration.
- [Current-head CI
passed](https://github.com/block/buzz/actions/runs/38659307247),
including Mobile and Mobile Swift. [Codex
review](https://github.com/block/buzz/pull/8332#issuecomment-6181458528)
and [security
review](https://github.com/block/buzz/pull/8332#issuecomment-6181449235)
found no issues on the integrated commit. [Owner
approval](https://github.com/block/buzz/pull/8332#pullrequestreview-5526812957)
covers the exact integrated commit.
- Standards and spec reviews found no blockers, including the merge
resolutions and test adjustments. Validation is scoped to mobile/native
per VISION_MOBILE; unrelated repository-wide checks were stopped, not
counted as passing.

The bridge test covers serialization and buffering, not end-to-end APNs
delivery or app launch. No new physical-device acceptance is claimed;
routine post-merge dogfood remains human-owned.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-14 18:42:39 -07:00

115 lines
3.5 KiB
Swift

import Foundation
/// A stable destination attached by the notification service extension after
/// it resolves and verifies the event that produced a push wake.
public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable {
public static let userInfoKey = "buzz_push_navigation"
public let eventID: String
public let communityID: String
public let channelID: String
/// Direct parent whose thread contains the target reply, matching Inbox.
public let threadRootID: String?
public init(
eventID: String, communityID: String, channelID: String, threadRootID: String? = nil
) {
self.eventID = eventID
self.communityID = communityID
self.channelID = channelID
self.threadRootID = threadRootID
}
public var userInfoValue: [String: String] {
var value = [
"event_id": eventID,
"community_id": communityID,
"channel_id": channelID,
]
value["thread_root_id"] = threadRootID
return value
}
static func threadRootID(in tags: [[String]]) -> String? {
// Inbox opens broadcast replies in the channel and other replies in their
// direct parent's thread, even when the NIP-10 root names an outer thread.
if tags.contains(where: { $0.count >= 2 && $0[0] == "broadcast" && $0[1] == "1" }) {
return nil
}
// Match relay ingest: the last valid 64-character ASCII-hex reply ID wins.
// A later malformed marker must not hide an earlier valid parent.
return tags.last(where: { tag in
guard tag.count >= 4, tag[0] == "e", tag[3] == "reply" else { return false }
let bytes = tag[1].utf8
return bytes.count == 64 && bytes.allSatisfy {
(48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0)
}
})?[1].lowercased()
}
/// Decodes a target without trusting other fields from the APNs payload.
public static func decodeIfPresent(
from userInfo: [AnyHashable: Any]
) -> BuzzPushNavigationTarget? {
guard let raw = userInfo[userInfoKey] as? [String: Any],
Set(raw.keys).isSubset(of: ["event_id", "community_id", "channel_id", "thread_root_id"]),
let eventID = raw["event_id"] as? String,
let communityID = raw["community_id"] as? String,
let channelID = raw["channel_id"] as? String,
!eventID.isEmpty,
!communityID.isEmpty,
!channelID.isEmpty
else {
return nil
}
if let rawThread = raw["thread_root_id"] {
guard let thread = rawThread as? String, !thread.isEmpty else { return nil }
}
return BuzzPushNavigationTarget(
eventID: eventID,
communityID: communityID,
channelID: channelID,
threadRootID: raw["thread_root_id"] as? String
)
}
}
/// Thread-safe one-item buffer spanning notification delivery and Flutter
/// engine startup during a cold notification launch.
public final class BuzzPushNavigationBuffer: @unchecked Sendable {
private let lock = NSLock()
private var target: BuzzPushNavigationTarget?
public init() {}
public func record(_ target: BuzzPushNavigationTarget) {
lock.lock()
self.target = target
lock.unlock()
}
public func peek() -> BuzzPushNavigationTarget? {
lock.lock()
defer { lock.unlock() }
return target
}
public func take() -> BuzzPushNavigationTarget? {
lock.lock()
defer { lock.unlock() }
let current = target
target = nil
return current
}
public func remove(ifMatching expected: BuzzPushNavigationTarget) {
lock.lock()
defer { lock.unlock() }
if target == expected {
target = nil
}
}
}