fix(ios): open push replies in their containing thread

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-13 09:31:58 -07:00
parent 799cfd7050
commit e062110b25
8 changed files with 230 additions and 7 deletions

View File

@@ -9,18 +9,26 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable {
public let communityID: String
public let channelID: String
public init(eventID: String, communityID: String, 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
}
/// Decodes a target without trusting other fields from the APNs payload.
@@ -28,7 +36,7 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable {
from userInfo: [AnyHashable: Any]
) -> BuzzPushNavigationTarget? {
guard let raw = userInfo[userInfoKey] as? [String: Any],
raw.count == 3,
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,
@@ -38,10 +46,14 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable {
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
channelID: channelID,
threadRootID: raw["thread_root_id"] as? String
)
}

View File

@@ -462,7 +462,8 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
BuzzPushNavigationTarget(
eventID: event.id,
communityID: community.id,
channelID: $0
channelID: $0,
threadRootID: navigationThreadRootID(event)
)
},
senderPubkey: event.pubkey.lowercased(),
@@ -473,6 +474,15 @@ 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,

View File

@@ -75,3 +75,31 @@ import Testing
#expect(buffer.take() == first)
#expect(buffer.take() == nil)
}
@Test func `Reply target preserves containing thread through user info and cold-start buffer`() {
let target = BuzzPushNavigationTarget(
eventID: "reply", communityID: "community", channelID: "channel", threadRootID: "parent"
)
let decoded = BuzzPushNavigationTarget.decodeIfPresent(
from: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue]
)
#expect(decoded == target)
let buffer = BuzzPushNavigationBuffer()
buffer.record(target)
#expect(buffer.take() == target)
}
@Test func `Reject malformed optional thread and unknown navigation fields`() {
let cases: [[String: Any]] = [
["thread_root_id": ""], ["thread_root_id": 42], ["unknown": "value"],
]
for extra in cases {
var raw: [String: Any] = [
"event_id": "reply", "community_id": "community", "channel_id": "channel",
]
raw.merge(extra) { _, new in new }
#expect(BuzzPushNavigationTarget.decodeIfPresent(
from: [BuzzPushNavigationTarget.userInfoKey: raw]
) == nil)
}
}

View File

@@ -162,6 +162,34 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
}
}
func testReplyNavigationUsesDirectParentAndKeepsExactEvent() throws {
let reply = event(id: "reply-id", content: "Reply preview", tags: [
["h", Self.channelID],
["e", "outer-root", "", "root"],
["e", "direct-parent", "", "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")
}
func testNavigationKeepsTopLevelAndBroadcastMessagesInChannel() throws {
for tags in [
[["h", Self.channelID]],
[["h", Self.channelID], ["e", "mention", "", "mention"]],
[["h", Self.channelID], ["e", "root", "", "root"]],
[["h", Self.channelID], ["e", "parent", "", "reply"], ["broadcast", "1"]],
] {
let resolution = try XCTUnwrap(BuzzPushNotificationResolver.decodeResolution(
events: [event(content: "Preview", tags: tags)], community: community()
)?.0)
XCTAssertNil(resolution.navigationTarget?.threadRootID)
}
}
func testResolveSucceedsAndMutatesGatewayContent() throws {
let event = try JSONDecoder().decode(
VerifiedNostrEvent.self,

View File

@@ -1074,10 +1074,12 @@ import os.log
extension BuzzPushNavigationTarget {
fileprivate var flutterArguments: [String: String] {
[
var arguments = [
"eventId": eventID,
"communityId": communityID,
"channelId": channelID,
]
arguments["threadRootId"] = threadRootID
return arguments
}
}

View File

@@ -106,18 +106,22 @@ MessageDeepLink? _pushNotificationLink(Object? arguments) {
final eventId = arguments['eventId'];
final communityId = arguments['communityId'];
final channelId = arguments['channelId'];
final threadRootId = arguments['threadRootId'];
if (eventId is! String ||
eventId.isEmpty ||
communityId is! String ||
communityId.isEmpty ||
channelId is! String ||
channelId.isEmpty) {
channelId.isEmpty ||
(threadRootId != null &&
(threadRootId is! String || threadRootId.isEmpty))) {
return null;
}
return MessageDeepLink(
communityId: communityId,
channelId: channelId,
messageId: eventId,
threadRootId: threadRootId as String?,
);
}

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:buzz/shared/push/push_bridge.dart';
import 'package:buzz/features/channels/channel_actions_sheet.dart';
import '../profile/presence_snapshot_test.dart'
@@ -11771,6 +11772,91 @@ void main() {
});
});
testWidgets('push tap highlights its exact reply after newer arrivals', (
tester,
) async {
pendingPushNotificationLink.value = null;
installBuzzPushMethodHandler();
addTearDown(() {
pendingPushNotificationLink.value = null;
});
const channel = MethodChannel('buzz/push');
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
channel.name,
channel.codec.encodeMethodCall(
const MethodCall('notificationOpened', {
'eventId': 'target',
'communityId': 'community',
'channelId': _channelId,
'threadRootId': 'parent',
}),
),
(_) {},
);
final link = pendingPushNotificationLink.value!;
final root = _textMsg(
id: 'root',
pubkey: 'alice',
content: 'Outer root',
createdAt: 1000,
);
final parent = _textMsg(
id: 'parent',
pubkey: 'alice',
content: 'Containing thread',
createdAt: 1100,
extraTags: const [
['e', 'root', '', 'reply'],
],
);
final target = _textMsg(
id: 'target',
pubkey: 'alice',
content: 'Notification reply',
createdAt: 1200,
extraTags: const [
['e', 'root', '', 'root'],
['e', 'parent', '', 'reply'],
],
);
final newer = _textMsg(
id: 'newer',
pubkey: 'alice',
content: 'Newer reply',
createdAt: 1300,
extraTags: const [
['e', 'root', '', 'root'],
['e', 'parent', '', 'reply'],
],
);
await tester.pumpWidget(
_buildTestable(
messages: [root, parent, target, newer],
initialMessageId: link.messageId,
initialThreadRootId: link.threadRootId,
threadReplies: {
'root': [parent, target, newer],
},
),
);
await tester.pumpAndSettle();
final page = tester.widget<ThreadDetailPage>(
find.byType(ThreadDetailPage),
);
expect(page.threadHead.id, 'parent');
expect(page.initialMessageId, 'target');
final decoration =
tester
.widget<DecoratedBox>(
find.byKey(const ValueKey('thread-message-target')),
)
.decoration
as BoxDecoration;
expect(decoration.color!.a, closeTo(0.12, 0.001));
expect(find.text('Newer reply'), findsOneWidget);
});
testWidgets('opens a nested reply in its direct-parent thread', (
tester,
) async {

View File

@@ -340,6 +340,57 @@ void main() {
);
});
test(
'preserves the exact reply and containing thread on a warm tap',
() async {
final response = Completer<ByteData?>();
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
_channel.name,
_channel.codec.encodeMethodCall(
const MethodCall('notificationOpened', {
'eventId': 'older-reply',
'communityId': 'community-id',
'channelId': 'channel-id',
'threadRootId': 'direct-parent',
}),
),
response.complete,
);
expect(
_channel.codec.decodeEnvelope((await response.future)!),
'handled',
);
expect(
pendingPushNotificationLink.value,
const MessageDeepLink(
communityId: 'community-id',
channelId: 'channel-id',
messageId: 'older-reply',
threadRootId: 'direct-parent',
),
);
},
);
for (final invalidThread in ['', 42]) {
test('rejects malformed thread ID $invalidThread', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
_channel,
(_) async => {
'eventId': 'reply',
'communityId': 'community',
'channelId': 'channel',
'threadRootId': invalidThread,
},
);
await syncPendingBuzzPushNotificationResponse();
expect(pendingPushNotificationLink.value, isNull);
});
}
test('rejects a notification response with an empty channel ID', () async {
final response = Completer<ByteData?>();
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
@@ -412,6 +463,7 @@ void main() {
expect(call.method, 'takePendingNotificationResponse');
return {
'eventId': 'b' * 64,
'threadRootId': 'a' * 64,
'communityId': 'community-id',
'channelId': '123e4567-e89b-42d3-a456-426614174000',
};
@@ -425,6 +477,7 @@ void main() {
communityId: 'community-id',
channelId: '123e4567-e89b-42d3-a456-426614174000',
messageId: 'b' * 64,
threadRootId: 'a' * 64,
),
);
});