From aa31a9e091bf962aee75a0070acf2bfa3804eddf Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 26 Aug 2026 10:37:48 -0700 Subject: [PATCH] fix(push-demo): preserve thread root for temporary replies Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../BuzzPushNotificationResolver.swift | 20 ++++++++-- .../BuzzPushNotificationResolverTests.swift | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index bd592d477f..7683521093 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -456,10 +456,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { let conversation = channel.flatMap { communicationConversation(event: event, community: community, channel: $0) } - let rootEventID = - event.tags.first { - $0.count >= 4 && $0[0] == "e" && $0[3] == "root" && !$0[1].isEmpty - }?[1] ?? event.id + let rootEventID = replyRootEventID(for: event) let replyKind = [45_001, 45_003].contains(event.kind) ? 45_003 : 9 return BuzzPushResolution( title: profile?.displayName ?? shortPubkey(event.pubkey), @@ -491,6 +488,21 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { ) } + /// Resolves the NIP-10 root carried by an incoming message. A direct reply + /// to a root commonly carries only a `reply` marker, so that parent is also + /// the thread root. Nested replies carry an explicit `root` marker, which + /// remains authoritative. + private static func replyRootEventID(for event: VerifiedNostrEvent) -> String { + for marker in ["root", "reply"] { + if let value = event.tags.first(where: { + $0.count >= 4 && $0[0] == "e" && $0[3] == marker && !$0[1].isEmpty + })?[1] { + return value + } + } + return event.id + } + private static func ephemeralProfile( event: VerifiedNostrEvent, communityID: String, diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift index 7fbcdd3f6a..72528eb15f 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -129,6 +129,43 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertEqual(result?.0.body, "lower ID") } + func testReplyContextUsesReplyMarkerAsRootForDirectReply() throws { + let incoming = event( + id: "incoming-reply", + content: "Reply to the thread root", + tags: [ + ["h", Self.channelID], + ["e", "thread-root", "", "reply"], + ] + ) + + let result = BuzzPushNotificationResolver.decodeResolution( + events: [incoming], + community: community() + ) + + XCTAssertEqual(try XCTUnwrap(result?.0.replyContext).rootEventID, "thread-root") + } + + func testReplyContextPrefersExplicitRootForNestedReply() throws { + let incoming = event( + id: "incoming-nested-reply", + content: "Nested reply", + tags: [ + ["h", Self.channelID], + ["e", "thread-root", "", "root"], + ["e", "immediate-parent", "", "reply"], + ] + ) + + let result = BuzzPushNotificationResolver.decodeResolution( + events: [incoming], + community: community() + ) + + XCTAssertEqual(try XCTUnwrap(result?.0.replyContext).rootEventID, "thread-root") + } + func testResolveSucceedsAndMutatesGatewayContent() throws { let event = try JSONDecoder().decode( VerifiedNostrEvent.self,