mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
fix(ios): open push replies in their containing thread
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user