fix(mobile): select the last valid push reply marker

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-14 10:27:03 -07:00
parent f465f9196f
commit 5b70e1a5cb
3 changed files with 32 additions and 9 deletions

View File

@@ -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.

View File

@@ -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)
}
}

View File

@@ -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 {