Skip to content

Commit 007877c

Browse files
pfefferleobenland
andauthored
Add support for a reply "context" (Automattic#1258)
* Add context see https://codeberg.org/fediverse/fep/src/branch/main/fep/7888/fep-7888.md * fix phpcs * Added changelog * update changelog * only return unique ids * add tests * fix phpcs issues * set context in post and comment transformer * fix phpcs issue * check for `false` * simplify code * fix phpcs * fix tests and use en code-comments * various fixes * update changelog * fix duplicate * only show comments * it is only type * be more desciptive * order collection * Update includes/collection/class-replies.php Co-authored-by: Konstantin Obenland <obenland@gmx.de> * fix Changelog --------- Co-authored-by: Konstantin Obenland <obenland@gmx.de>
1 parent 2c49e6f commit 007877c

File tree

8 files changed

+210
-6
lines changed

8 files changed

+210
-6
lines changed

‎CHANGELOG.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
* A reply `context` for Posts and Comments to allow relying parties to discover the whole conversation of a thread.
1213
* Setting to adjust the number of days Outbox items are kept before being purged.
14+
* Undo API for Outbox items.
1315
* Metadata to New Follower E-Mail.
1416

1517
### Changed
@@ -19,7 +21,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1921
### Fixed
2022

2123
* The Outbox purging routine no longer is limited to deleting 5 items at a time.
22-
* Undo API for Outbox items.
2324
* Allow Activities on URLs instead of requiring Activity-Objects. This is useful especially for sending Announces and Likes.
2425
* Ellipses now display correctly in notification emails for Likes and Reposts.
2526

‎FEDERATION.md

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ The WordPress plugin largely follows ActivityPub's server-to-server specificatio
1818
- [FEP-2c59: Discovery of a Webfinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md)
1919
- [FEP-fb2a: Actor metadata](https://codeberg.org/fediverse/fep/src/branch/main/fep/fb2a/fep-fb2a.md)
2020
- [FEP-b2b8: Long-form Text](https://codeberg.org/fediverse/fep/src/branch/main/fep/b2b8/fep-b2b8.md)
21+
- [FEP-7888: Demystifying the context property](https://codeberg.org/fediverse/fep/src/branch/main/fep/7888/fep-7888.md)
2122

2223
Partially supported FEPs
2324

‎includes/collection/class-replies.php

+49-5
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
use WP_Error;
1313

1414
use Activitypub\Comment;
15+
use Activitypub\Transformer\Post as PostTransformer;
16+
use Activitypub\Transformer\Comment as CommentTransformer;
1517

18+
use function Activitypub\is_post_disabled;
1619
use function Activitypub\is_local_comment;
1720
use function Activitypub\get_rest_url_by_path;
1821

@@ -141,20 +144,56 @@ public static function get_collection_page( $wp_object, $page, $part_of = null )
141144
return $collection_page;
142145
}
143146

147+
/**
148+
* Get the context collection for a post.
149+
*
150+
* @param int $post_id The post ID.
151+
*
152+
* @return array|false The context for the post or false if the post is not found or disabled.
153+
*/
154+
public static function get_context_collection( $post_id ) {
155+
$post = \get_post( $post_id );
156+
157+
if ( ! $post || is_post_disabled( $post_id ) ) {
158+
return false;
159+
}
160+
161+
$comments = \get_comments(
162+
array(
163+
'post_id' => $post_id,
164+
'type' => 'comment',
165+
'status' => 'approve',
166+
'orderby' => 'comment_date_gmt',
167+
'order' => 'ASC',
168+
)
169+
);
170+
$ids = self::get_reply_ids( $comments, true );
171+
$post_uri = ( new PostTransformer( $post ) )->to_id();
172+
\array_unshift( $ids, $post_uri );
173+
174+
return array(
175+
'type' => 'OrderedCollection',
176+
'url' => \get_permalink( $post_id ),
177+
'attributedTo' => Actors::get_by_id( $post->post_author )->get_id(),
178+
'totalItems' => count( $ids ),
179+
'items' => $ids,
180+
);
181+
}
182+
144183
/**
145184
* Get the ActivityPub ID's from a list of comments.
146185
*
147186
* It takes only federated/non-local comments into account, others also do not have an
148187
* ActivityPub ID available.
149188
*
150-
* @param WP_Comment[] $comments The comments to retrieve the ActivityPub ids from.
189+
* @param WP_Comment[] $comments The comments to retrieve the ActivityPub ids from.
190+
* @param boolean $include_blog_comments Optional. Include blog comments in the returned array. Default false.
151191
*
152192
* @return string[] A list of the ActivityPub ID's.
153193
*/
154-
private static function get_reply_ids( $comments ) {
194+
private static function get_reply_ids( $comments, $include_blog_comments = false ) {
155195
$comment_ids = array();
156-
// Only add external comments from the fediverse.
157-
// Maybe use the Comment class more and the function is_local_comment etc.
196+
158197
foreach ( $comments as $comment ) {
159198
if ( is_local_comment( $comment ) ) {
160199
continue;
@@ -163,9 +202,14 @@ private static function get_reply_ids( $comments ) {
163202
$public_comment_id = Comment::get_source_id( $comment->comment_ID );
164203
if ( $public_comment_id ) {
165204
$comment_ids[] = $public_comment_id;
205+
continue;
206+
}
207+
208+
if ( $include_blog_comments ) {
209+
$comment_ids[] = ( new CommentTransformer( $comment ) )->to_id();
166210
}
167211
}
168212

169-
return $comment_ids;
213+
return \array_unique( $comment_ids );
170214
}
171215
}

‎includes/rest/class-post.php

+50
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
use WP_REST_Response;
1212
use WP_Error;
1313
use Activitypub\Comment;
14+
use Activitypub\Activity\Base_Object;
15+
use Activitypub\Collection\Replies;
16+
17+
use function Activitypub\get_rest_url_by_path;
1418

1519
/**
1620
* Class Post
@@ -45,6 +49,22 @@ public static function register_routes() {
4549
),
4650
)
4751
);
52+
53+
register_rest_route(
54+
ACTIVITYPUB_REST_NAMESPACE,
55+
'/posts/(?P<id>\d+)/context',
56+
array(
57+
'methods' => WP_REST_Server::READABLE,
58+
'callback' => array( static::class, 'get_context' ),
59+
'permission_callback' => '__return_true',
60+
'args' => array(
61+
'id' => array(
62+
'required' => true,
63+
'type' => 'integer',
64+
),
65+
),
66+
)
67+
);
4868
}
4969

5070
/**
@@ -107,4 +127,34 @@ function ( $comment ) {
107127

108128
return new WP_REST_Response( $reactions );
109129
}
130+
131+
/**
132+
* Get the context for a post.
133+
*
134+
* @param \WP_REST_Request $request The request.
135+
*
136+
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
137+
*/
138+
public static function get_context( $request ) {
139+
$post_id = $request->get_param( 'id' );
140+
141+
$collection = Replies::get_context_collection( $post_id );
142+
143+
if ( false === $collection ) {
144+
return new WP_Error( 'post_not_found', 'Post not found', array( 'status' => 404 ) );
145+
}
146+
147+
$response = array_merge(
148+
array(
149+
'@context' => Base_Object::JSON_LD_CONTEXT,
150+
'id' => get_rest_url_by_path( sprintf( 'posts/%d/context', $post_id ) ),
151+
),
152+
$collection
153+
);
154+
155+
$response = \rest_ensure_response( $response );
156+
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
157+
158+
return $response;
159+
}
110160
}

‎includes/transformer/class-comment.php

+15
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,21 @@ public function get_type() {
336336
return 'Note';
337337
}
338338

339+
/**
340+
* Get the context of the post.
341+
*
342+
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-context
343+
*
344+
* @return string The context of the post.
345+
*/
346+
protected function get_context() {
347+
if ( $this->item->comment_post_ID ) {
348+
return get_rest_url_by_path( sprintf( 'posts/%d/context', $this->item->comment_post_ID ) );
349+
}
350+
351+
return null;
352+
}
353+
339354
/**
340355
* Get the replies Collection.
341356
*

‎includes/transformer/class-post.php

+11
Original file line numberDiff line numberDiff line change
@@ -1068,6 +1068,17 @@ protected function get_wordpress_attachment( $id, $image_size = 'large' ) {
10681068
return $image;
10691069
}
10701070

1071+
/**
1072+
* Get the context of the post.
1073+
*
1074+
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-context
1075+
*
1076+
* @return string The context of the post.
1077+
*/
1078+
protected function get_context() {
1079+
return get_rest_url_by_path( sprintf( 'posts/%d/context', $this->item->ID ) );
1080+
}
1081+
10711082
/**
10721083
* Gets the template to use to generate the content of the activitypub item.
10731084
*

‎readme.txt

+1
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ For reasons of data protection, it is not possible to see the followers of other
131131

132132
= Unreleased =
133133

134+
* Added: A reply `context` for Posts and Comments to allow relying parties to discover the whole conversation of a thread.
134135
* Added: Allow Activities on URLs instead of requiring Activity-Objects. This is useful especially for sending Announces and Likes.
135136
* Added: Undo API for Outbox items.
136137
* Added: Setting to adjust the number of days Outbox items are kept before being purged.

‎tests/includes/collection/class-test-replies.php

+81
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,85 @@ public function test_replies_collection_of_post_with_federated_comments() {
5656
$this->assertCount( 1, $replies['first']['items'] );
5757
$this->assertEquals( $replies['first']['items'][0], $source_id );
5858
}
59+
60+
/**
61+
* Test get_context_collection method.
62+
*
63+
* @covers ::get_context_collection
64+
*/
65+
public function test_get_context_collection() {
66+
// Create a test post.
67+
$context_post_id = self::factory()->post->create(
68+
array(
69+
'post_author' => 1,
70+
)
71+
);
72+
73+
// Test with disabled post.
74+
add_post_meta( $context_post_id, 'activitypub_content_visibility', ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL );
75+
$this->assertFalse( Replies::get_context_collection( $context_post_id ), 'Should return false for disabled posts' );
76+
delete_post_meta( $context_post_id, 'activitypub_content_visibility' );
77+
78+
// Test with non-existent post.
79+
$this->assertFalse( Replies::get_context_collection( 999999 ), 'Should return false for non-existent posts' );
80+
81+
// Test without comments.
82+
$context = Replies::get_context_collection( $context_post_id );
83+
$this->assertIsArray( $context, 'Should return an array for posts without comments' );
84+
$this->assertCount( 1, $context['items'], 'Array should contain only one item for posts without comments' );
85+
86+
// Create test comments.
87+
$comments = array();
88+
89+
// Local comment.
90+
$comments[] = self::factory()->comment->create(
91+
array(
92+
'comment_post_ID' => $context_post_id,
93+
'comment_content' => 'Local comment',
94+
'comment_approved' => '1',
95+
'comment_meta' => array(
96+
'activitypub_status' => 'federated',
97+
),
98+
)
99+
);
100+
101+
// ActivityPub comment.
102+
$comments[] = self::factory()->comment->create(
103+
array(
104+
'comment_post_ID' => $context_post_id,
105+
'comment_content' => 'ActivityPub comment',
106+
'comment_approved' => '1',
107+
'comment_meta' => array(
108+
'protocol' => 'activitypub',
109+
'source_id' => 'https://example.com/comment/1',
110+
),
111+
)
112+
);
113+
114+
// Test with comments.
115+
$context = Replies::get_context_collection( $context_post_id );
116+
117+
$this->assertIsArray( $context, 'Should return an array' );
118+
$this->assertEquals( 'OrderedCollection', $context['type'], 'Should be of type OrderedCollection' );
119+
$this->assertEquals( get_permalink( $context_post_id ), $context['url'], 'Should contain the post URL' );
120+
$this->assertArrayHasKey( 'attributedTo', $context, 'Should contain attributedTo' );
121+
$this->assertArrayHasKey( 'totalItems', $context, 'Should contain totalItems' );
122+
$this->assertArrayHasKey( 'items', $context, 'Should contain items' );
123+
124+
// Check the number of items (Post + all comments).
125+
$this->assertEquals( 3, $context['totalItems'], 'Should count Post + all comments' );
126+
$this->assertCount( 3, $context['items'], 'Items should contain Post + all comments' );
127+
128+
// Check that the post URI is the first item.
129+
$this->assertStringContainsString( (string) $context_post_id, $context['items'][0], 'First item should be the post URI' );
130+
131+
// Check that the ActivityPub comment is contained.
132+
$this->assertContains( 'https://example.com/comment/1', $context['items'], 'Should contain ActivityPub comment ID' );
133+
134+
// Clean up.
135+
wp_delete_post( $context_post_id, true );
136+
foreach ( $comments as $comment_id ) {
137+
wp_delete_comment( $comment_id, true );
138+
}
139+
}
59140
}

0 commit comments

Comments
 (0)