From 42816d4272e2d942fd7b35517a8cdf46131f52bd Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 13 Oct 2026 09:41:57 -0700 Subject: [PATCH] fix(ios): preserve navigation for empty reply markers Signed-off-by: Tom Brow --- .../BuzzPushKit/BuzzPushNavigationTarget.swift | 13 +++++++++++++ .../BuzzPushNotificationResolver.swift | 11 +---------- .../BuzzPushNavigationTargetTests.swift | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift index dccf62c6a9..2c6605719a 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift @@ -31,6 +31,19 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable { 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 + } + let parent = tags.last(where: { $0.count >= 4 && $0[0] == "e" && $0[3] == "reply" })?[1] + // Relay ingest ignores malformed reply IDs. An empty marker must not + // invalidate the otherwise usable channel/event navigation payload. + guard let parent, !parent.isEmpty else { return nil } + return parent + } + /// Decodes a target without trusting other fields from the APNs payload. public static func decodeIfPresent( from userInfo: [AnyHashable: Any] diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index 5fd9f30876..d195748021 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -463,7 +463,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { eventID: event.id, communityID: community.id, channelID: $0, - threadRootID: navigationThreadRootID(event) + threadRootID: BuzzPushNavigationTarget.threadRootID(in: event.tags) ) }, senderPubkey: event.pubkey.lowercased(), @@ -474,15 +474,6 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { ) } - private static func navigationThreadRootID(_ event: VerifiedNostrEvent) -> 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 event.tags.contains(where: { $0.count >= 2 && $0[0] == "broadcast" && $0[1] == "1" }) { - return nil - } - return event.tags.last(where: { $0.count >= 4 && $0[0] == "e" && $0[3] == "reply" })?[1] - } - private static func ephemeralProfile( event: VerifiedNostrEvent, communityID: String, diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift index f02e35770a..660945eb6d 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift @@ -103,3 +103,21 @@ import Testing ) == nil) } } + +@Test func `Empty reply marker preserves channel navigation`() { + let thread = BuzzPushNavigationTarget.threadRootID(in: [["e", "", "", "reply"]]) + #expect(thread == nil) + let target = BuzzPushNavigationTarget( + eventID: "message", communityID: "community", channelID: "channel", threadRootID: thread + ) + #expect(BuzzPushNavigationTarget.decodeIfPresent( + from: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue] + ) == target) +} + +@Test func `Thread destination keeps direct parent and broadcast semantics`() { + let tags = [["e", "outer-root", "", "root"], ["e", "direct-parent", "", "reply"]] + #expect(BuzzPushNavigationTarget.threadRootID(in: tags) == "direct-parent") + #expect(BuzzPushNavigationTarget.threadRootID(in: tags + [["broadcast", "1"]]) == nil) + #expect(BuzzPushNavigationTarget.threadRootID(in: [["e", "outer-root", "", "root"]]) == nil) +}