diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift index 2c6605719a..c6cb813648 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift @@ -37,11 +37,15 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable { 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 + // 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] } /// Decodes a target without trusting other fields from the APNs payload. diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift index 660945eb6d..c2bba98b7d 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift @@ -116,8 +116,24 @@ import Testing } @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") + let parent = String(repeating: "a", count: 64) + let tags = [["e", "outer-root", "", "root"], ["e", parent, "", "reply"]] + #expect(BuzzPushNavigationTarget.threadRootID(in: tags) == parent) #expect(BuzzPushNavigationTarget.threadRootID(in: tags + [["broadcast", "1"]]) == nil) #expect(BuzzPushNavigationTarget.threadRootID(in: [["e", "outer-root", "", "root"]]) == nil) } + +@Test func `Last valid reply marker wins despite trailing malformed markers`() { + let first = String(repeating: "a", count: 64) + let last = String(repeating: "B", count: 64) + let invalidIDs = ["", "bad", String(repeating: "a", count: 63), + String(repeating: "a", count: 65), String(repeating: "g", count: 64), + String(repeating: "é", count: 64)] + for invalid in invalidIDs { + let invalidTag = ["e", invalid, "", "reply"] + #expect(BuzzPushNavigationTarget.threadRootID(in: [invalidTag]) == nil) + #expect(BuzzPushNavigationTarget.threadRootID(in: [ + ["e", first, "", "reply"], ["e", last, "", "reply"], invalidTag, + ]) == last) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift index 643cc050e4..8d8de632be 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -163,17 +163,20 @@ final class BuzzPushNotificationResolverTests: XCTestCase { } func testReplyNavigationUsesDirectParentAndKeepsExactEvent() throws { + let parent = String(repeating: "a", count: 64) let reply = event(id: "reply-id", content: "Reply preview", tags: [ ["h", Self.channelID], ["e", "outer-root", "", "root"], - ["e", "direct-parent", "", "reply"], + ["e", parent, "", "reply"], + ["e", "bad", "", "reply"], + ["e", "", "", "reply"], ]) let resolution = try XCTUnwrap(BuzzPushNotificationResolver.decodeResolution( events: [reply], community: community() )?.0) let target = try XCTUnwrap(resolution.navigationTarget) XCTAssertEqual(target.eventID, "reply-id") - XCTAssertEqual(target.userInfoValue["thread_root_id"], "direct-parent") + XCTAssertEqual(target.userInfoValue["thread_root_id"], parent) } func testNavigationKeepsTopLevelAndBroadcastMessagesInChannel() throws {