Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add replies collection #876

Merged
merged 23 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a3ba5d2
typo in phpdoc
Menrath Sep 1, 2024
a236066
add first draft for adding replies collections to posts and comments
Menrath Sep 1, 2024
d7a8034
refactoring
Menrath Sep 1, 2024
d9fc81a
Fix php CodeSniffer violations
Menrath Sep 1, 2024
a6835a4
fix typo in php comment
Menrath Sep 1, 2024
a9a22a9
add draft for testing replies
Menrath Sep 1, 2024
b1413cb
replies: test with own comment
Menrath Sep 1, 2024
ee3b19b
fix basic test for replies collection
Menrath Sep 1, 2024
ba968d3
Merge branch 'master' into replies_collection
pfefferle Sep 4, 2024
53c751f
Merge branch 'master' into replies_collection
pfefferle Sep 10, 2024
afaf2ad
Merge branch 'master' into replies_collection
pfefferle Sep 10, 2024
c6f472d
Merge branch 'master' into replies_collection
pfefferle Sep 13, 2024
60ffb65
Restrict 'type' parameter for replies to 'post' or 'comment' in REST API
Menrath Sep 14, 2024
2037579
some cleanups
pfefferle Sep 14, 2024
103919a
Merge branch 'master' into replies_collection
pfefferle Sep 16, 2024
3b5accc
prefer ID over URL
pfefferle Sep 16, 2024
2e9e740
rename to `reply_id` to make clear that it is not the WordPress comme…
pfefferle Sep 16, 2024
3b13b9d
Merge branch 'master' into replies_collection
pfefferle Sep 19, 2024
1a9e638
Merge branch 'master' into replies_collection
pfefferle Sep 20, 2024
769e434
modularize retrieving of comment link via comment meta
Menrath Sep 23, 2024
44954c6
Merge branch 'master' into replies_collection
Menrath Sep 23, 2024
7cc237a
fix phpcs
Menrath Sep 23, 2024
e22eb49
I think we should be more precise with this
pfefferle Sep 25, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion activitypub.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
\defined( 'ACTIVITYPUB_AUTHORIZED_FETCH' ) || \define( 'ACTIVITYPUB_AUTHORIZED_FETCH', false );
\defined( 'ACTIVITYPUB_DISABLE_REWRITES' ) || \define( 'ACTIVITYPUB_DISABLE_REWRITES', false );
\defined( 'ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS' ) || \define( 'ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS', false );
// Disable reactions like `Like` and `Accounce` by default
// Disable reactions like `Like` and `Announce` by default
\defined( 'ACTIVITYPUB_DISABLE_REACTIONS' ) || \define( 'ACTIVITYPUB_DISABLE_REACTIONS', true );
\defined( 'ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS' ) || \define( 'ACTIVITYPUB_DISABLE_OUTGOING_INTERACTIONS', false );
\defined( 'ACTIVITYPUB_SHARED_INBOX_FEATURE' ) || \define( 'ACTIVITYPUB_SHARED_INBOX_FEATURE', false );
Expand Down
15 changes: 15 additions & 0 deletions includes/activity/class-activity.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@ class Activity extends Base_Object {
*/
protected $result;

/**
* Identifies a Collection containing objects considered to be responses
* to this object.
* WordPress has a strong core system of approving replies. We only include
* approved replies here.
*
* @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-replies
*
* @var array
* | ObjectType
* | Link
* | null
*/
protected $replies;

/**
* An indirect object of the activity from which the
* activity is directed.
Expand Down
2 changes: 1 addition & 1 deletion includes/collection/class-followers.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ public static function get_follower( $user_id, $actor ) {
}

/**
* Get a Follower by Actor indepenent from the User.
* Get a Follower by Actor independent from the User.
*
* @param string $actor The Actor URL.
*
Expand Down
175 changes: 175 additions & 0 deletions includes/collection/class-replies.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
<?php
namespace Activitypub\Collection;

use WP_Post;
use WP_Comment;
use WP_Error;

use function Activitypub\is_local_comment;
use function Activitypub\get_rest_url_by_path;

/**
* Class containing code for getting replies Collections and CollectionPages of posts and comments.
*/
class Replies {
Menrath marked this conversation as resolved.
Show resolved Hide resolved
/**
* Build base arguments for fetching the comments of either a WordPress post or comment.
*
* @param WP_Post|WP_Comment $wp_object
*/
private static function build_args( $wp_object ) {
$args = array(
'status' => 'approve',
'orderby' => 'comment_date_gmt',
'order' => 'ASC',
);

if ( $wp_object instanceof WP_Post ) {
$args['parent'] = 0; // TODO: maybe this is unnecessary.
$args['post_id'] = $wp_object->ID;
} elseif ( $wp_object instanceof WP_Comment ) {
$args['parent'] = $wp_object->comment_ID;
} else {
return new WP_Error();
}

return $args;
}

/**
* Adds pagination args comments query.
*
* @param array $args Query args built by self::build_args.
* @param int $page The current pagination page.
* @param int $comments_per_page The number of comments per page.
*/
private static function add_pagination_args( $args, $page, $comments_per_page ) {
$args['number'] = $comments_per_page;

$offset = intval( $page ) * $comments_per_page;
$args['offset'] = $offset;

return $args;
}


/**
* Get the replies collections ID.
*
* @param WP_Post|WP_Comment $wp_object
*
* @return string The rest URL of the replies collection.
*/
private static function get_replies_id( $wp_object ) {
if ( $wp_object instanceof WP_Post ) {
return get_rest_url_by_path( sprintf( 'posts/%d/replies', $wp_object->ID ) );
} elseif ( $wp_object instanceof WP_Comment ) {
return get_rest_url_by_path( sprintf( 'comments/%d/replies', $wp_object->comment_ID ) );
} else {
return new WP_Error();
}
}

/**
* Get the replies collection.
*
* @param WP_Post|WP_Comment $wp_object
* @param int $page
*
* @return array An associative array containing the replies collection without JSON-LD context.
*/
public static function get_collection( $wp_object ) {
Menrath marked this conversation as resolved.
Show resolved Hide resolved
$id = self::get_replies_id( $wp_object );

if ( ! $id ) {
return null;
}

$replies = array(
'id' => $id,
'type' => 'Collection',
);

$replies['first'] = self::get_collection_page( $wp_object, 0, $replies['id'] );

return $replies;
}

/**
* Get the ActivityPub ID's from a list of comments.
*
* It takes only federated/non-local comments into account, others also do not have an
* ActivityPub ID available.
*
* @param WP_Comment[] $comments The comments to retrieve the ActivityPub ids from.
*
* @return string[] A list of the ActivityPub ID's.
*/
private static function get_activitypub_comment_ids( $comments ) {
$comment_ids = array();
// Only add external comments from the fediverse.
// Maybe use the Comment class more and the function is_local_comment etc.
foreach ( $comments as $comment ) {
if ( is_local_comment( $comment ) ) {
continue;
}
$comment_meta = \get_comment_meta( $comment->comment_ID );
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This "if elseif" clause is now present 3-5 times in the code, sometimes slightly different. I have not fully understood why it is slightly different in some cases. But it seems this could be modularised into a function somewhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pfefferle Should someone have a look at this before this PR gets merged? I could find time in the next days.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but be aware that there are two different usecases:

  1. We want to have the ID with URL as fallback.
  2. We want to have the URL with ID as fallback.

Copy link
Contributor Author

@Menrath Menrath Sep 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

769e434 added a static function to the Comment util class, that tries to get the link via the comments meta. At some points I thought better error-catching could be possible, but I think this is beyond the scope of this PR. We could also just merge the current state, and revert the commit and add another PR for refactoring the code for comment related stuff.

if ( ! empty( $comment_meta['source_url'][0] ) ) {
$comment_ids[] = $comment_meta['source_url'][0];
} elseif ( ! empty( $comment_meta['source_id'][0] ) ) {
$comment_ids[] = $comment_meta['source_id'][0];
}
}
return $comment_ids;
}

/**
* Returns a replies collection page as an associative array.
*
* @link https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage
*
* @param WP_Post|WP_Comment $wp_object The post of comment the replies are for.
* @param int $page The current pagination page.
* @param string $part_of The collection id/url the returned CollectionPage belongs to.
*
* @return array A CollectionPage as an associative array.
*/
public static function get_collection_page( $wp_object, $page, $part_of = null ) {
// Build initial arguments for fetching approved comments.
$args = self::build_args( $wp_object );

// Retrieve the partOf if not already given.
$part_of = $part_of ?? self::get_replies_id( $wp_object );

// If the collection page does not exist.
if ( is_wp_error( $args ) || is_wp_error( $part_of ) ) {
return null;
}

// Get to total replies count.
$total_replies = \get_comments( array_merge( $args, array( 'count' => true ) ) );

// Modify query args to retrieve paginated results.
$comments_per_page = \get_option( 'comments_per_page' );

// Fetch internal and external comments for current page.
$comments = get_comments( self::add_pagination_args( $args, $page, $comments_per_page ) );

// Get the ActivityPub ID's of the comments, without out local-only comments.
$comment_ids = self::get_activitypub_comment_ids( $comments );

// Build the associative CollectionPage array.
$collection_page = array(
'id' => \add_query_arg( 'page', $page, $part_of ),
'type' => 'CollectionPage',
'partOf' => $part_of,
'items' => $comment_ids,
);

if ( $total_replies / $comments_per_page > $page + 1 ) {
$collection_page['next'] = \add_query_arg( 'page', $page + 1, $part_of );
}

return $collection_page;
}
}
94 changes: 93 additions & 1 deletion includes/rest/class-collection.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
use Activitypub\Activity\Actor;
use Activitypub\Activity\Base_Object;
use Activitypub\Collection\Users as User_Collection;
use Activitypub\Collection\Replies;

use Activitypub\Transformer\Factory;
use WP_Error;

use function Activitypub\esc_hashtag;
use function Activitypub\is_single_user;
Expand Down Expand Up @@ -69,6 +72,73 @@ public static function register_routes() {
),
)
);

\register_rest_route(
ACTIVITYPUB_REST_NAMESPACE,
'/(?P<type>[\w\-\.]+)s/(?P<id>[\w\-\.]+)/replies',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( self::class, 'replies_get' ),
'args' => self::request_parameters_for_replies(),
'permission_callback' => '__return_true',
),
)
);
}

/**
* The endpoint for replies collections
*
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response The response object.
*/
public static function replies_get( $request ) {
$type = $request->get_param( 'type' );

// Get the WordPress object of that "owns" the requested replies.
switch ( $type ) {
case 'comment':
$wp_object = \get_comment( $request->get_param( 'id' ) );
break;
case 'post':
default:
$wp_object = \get_post( $request->get_param( 'id' ) );
break;
}

if ( ! isset( $wp_object ) || is_wp_error( $wp_object ) ) {
return new WP_Error(
'activitypub_replies_collection_does_not_exist',
\sprintf(
// translators: %s: The type (post, comment, etc.) for which no replies collection exists.
\__( 'No reply collection exists for the type %s.', 'activitypub' ),
$type
)
);
}

$page = intval( $request->get_param( 'page' ) );

// If the request parameter page is present get the CollectionPage otherwise the replies collection.
if ( isset( $page ) ) {
$response = Replies::get_collection_page( $wp_object, $page );
} else {
$response = Replies::get_collection( $wp_object );
}

if ( is_wp_error( $response ) ) {
return $response;
}

// Add ActivityPub Context.
$response = array_merge(
array( '@context' => Base_Object::JSON_LD_CONTEXT ),
$response
);

return new WP_REST_Response( $response, 200 );
}

/**
Expand Down Expand Up @@ -220,7 +290,29 @@ public static function request_parameters() {

$params['user_id'] = array(
'required' => true,
'type' => 'string',
'type' => 'string',
);

return $params;
}

/**
* The supported parameters
*
* @return array list of parameters
*/
public static function request_parameters_for_replies() {
$params = array();

$params['type'] = array(
Menrath marked this conversation as resolved.
Show resolved Hide resolved
'required' => true,
'type' => 'string',
'enum' => array( 'post', 'comment' ),
);

$params['id'] = array(
'required' => true,
'type' => 'string',
);

return $params;
Expand Down
13 changes: 12 additions & 1 deletion includes/transformer/class-base.php
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
<?php
namespace Activitypub\Transformer;

use WP_Error;
use WP_Post;
use WP_Comment;

use Activitypub\Activity\Activity;
use Activitypub\Activity\Base_Object;
use Activitypub\Collection\Replies;


/**
* WordPress Base Transformer
Expand Down Expand Up @@ -108,6 +109,16 @@ public function to_activity( $type ) {
return $activity;
}

abstract protected function get_id();

/**
* Get the replies Collection.
*/
public function get_replies() {
$replies = Replies::get_collection( $this->wp_object, $this->get_id() );
return $replies;
}

/**
* Returns the ID of the WordPress Object.
*
Expand Down
2 changes: 1 addition & 1 deletion includes/transformer/class-post.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ protected function get_actor_object() {
*
* @return string The Posts ID.
*/
public function get_id() {
protected function get_id() {
return $this->get_url();
}

Expand Down
Loading
Loading