Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
3afe849
Add support for quote comments and improve quote detection
pfefferle Oct 17, 2025
a6b77d4
Merge branch 'trunk' into store-quotes
pfefferle Oct 17, 2025
4b3b44d
Update includes/functions.php
pfefferle Oct 17, 2025
f3a587c
Update includes/collection/class-interactions.php
pfefferle Oct 17, 2025
2a8353b
Refactor comment addition validation logic
pfefferle Oct 17, 2025
960dfe1
Add facepile display style for comment types
pfefferle Oct 17, 2025
f3e6cf9
Merge branch 'trunk' into store-quotes
pfefferle Oct 20, 2025
5553a2a
Merge branch 'trunk' into store-quotes
pfefferle Oct 20, 2025
d01494f
Merge branch 'trunk' into store-quotes
pfefferle Oct 22, 2025
22be295
Merge branch 'trunk' into store-quotes
pfefferle Nov 5, 2025
c011031
Remove facepile display logic from comment types
pfefferle Nov 5, 2025
3893740
Add tests for esc_hashtag function and filter
pfefferle Nov 5, 2025
ad6d41a
Add changelog
matticbot Nov 5, 2025
007d4db
Update includes/functions.php
pfefferle Nov 8, 2025
33839fe
Add support for quote activities in Fediverse
pfefferle Nov 10, 2025
3ff06ac
Merge branch 'trunk' into store-quotes
pfefferle Nov 10, 2025
a1ebc93
Skip camelCase conversion for keys prefixed with '_'
pfefferle Nov 10, 2025
79dcadd
Update includes/activity/class-base-object.php
pfefferle Nov 10, 2025
f96fe1c
Update includes/activity/class-base-object.php
pfefferle Nov 10, 2025
2eb2486
Update tests/phpunit/tests/includes/class-test-functions.php
pfefferle Nov 10, 2025
cfa6544
Add quote-related properties to Base_Object
pfefferle Nov 10, 2025
5d4187f
Remove translation from activity descriptions
pfefferle Nov 10, 2025
2e58dbe
Merge branch 'trunk' into store-quotes
pfefferle Nov 10, 2025
9b20c10
Fix camelCase to snake_case conversion logic
pfefferle Nov 10, 2025
7d4d27a
Rename is_activity_quote to is_quote_activity
pfefferle Nov 11, 2025
818ada9
Merge branch 'trunk' into store-quotes
pfefferle Nov 11, 2025
34c58ed
Fix snake_case conversion logic in Generic_Object
pfefferle Nov 11, 2025
adc9bc1
Update @covers annotations with full namespace
pfefferle Nov 11, 2025
b646fcb
Merge branch 'trunk' into store-quotes
pfefferle Nov 11, 2025
d4b193c
Merge branch 'trunk' into store-quotes
pfefferle Nov 11, 2025
e8b42e8
Add 'quote' to Jetpack comment types
pfefferle Nov 11, 2025
62be0dc
Merge branch 'trunk' into store-quotes
pfefferle Nov 13, 2025
b9a94b1
Handle quote comment deletion with Reject activity
pfefferle Nov 13, 2025
9213e0c
Merge branch 'trunk' into reject-quotes
pfefferle Nov 14, 2025
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
57 changes: 56 additions & 1 deletion includes/collection/class-inbox.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,17 @@ public static function add( $activity, $recipients ) {
$title = self::get_object_title( $activity->get_object() );
$visibility = is_activity_public( $activity ) ? ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC : ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE;

/*
* For activities with an 'instrument' property (e.g., QuoteRequest), we store
* the instrument URL as the object_id. This allows efficient querying by instrument.
* For all other activities, we store the object URL as before.
*/
if ( $activity->has( 'instrument' ) && $activity->get_instrument() ) {
$object_id = object_to_uri( $activity->get_instrument() );
} else {
$object_id = object_to_uri( $activity->get_object() );
}
Comment on lines +86 to +95
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

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

Consider documenting the side effect of this change on existing code that may query _activitypub_object_id meta. For QuoteRequest activities, the _activitypub_object_id now stores the instrument URL instead of the object URL. This is a breaking change in the meta structure. While this is necessary for the new feature, adding a note in the comment about backwards compatibility or migration considerations would be helpful. For instance, any existing code that queries by object URL for QuoteRequest activities will no longer find them.

Copilot uses AI. Check for mistakes.
Comment on lines +91 to +95
Copy link
Member

Choose a reason for hiding this comment

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

Is this a situation where $object_id = object_to_uri( $activity->get_instrument() ?: $activity->get_object() ); could work?

Copy link
Member Author

Choose a reason for hiding this comment

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

maybe... I was not sure if we can simply always prioritize instrument over object or if I also have to check for a *Request type.

Copy link
Member

Choose a reason for hiding this comment

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

Looks like at least Friendica has a different interpretation of what instrument is 💪


$inbox_item = array(
'post_type' => self::POST_TYPE,
'post_title' => sprintf(
Expand All @@ -96,7 +107,7 @@ public static function add( $activity, $recipients ) {
'post_status' => 'publish',
'guid' => $activity->get_id(),
'meta_input' => array(
'_activitypub_object_id' => object_to_uri( $activity->get_object() ),
'_activitypub_object_id' => $object_id,
'_activitypub_activity_type' => $activity->get_type(),
'_activitypub_activity_remote_actor' => object_to_uri( $activity->get_actor() ),
'activitypub_content_visibility' => $visibility,
Expand Down Expand Up @@ -369,6 +380,50 @@ public static function get_by_guid_and_recipient( $guid, $user_id ) {
return $post;
}

/**
* Get an inbox item by activity type and object ID.
*
* This is useful for finding specific activity types (like QuoteRequest)
* by their object identifier. For QuoteRequest activities, the object_id
* is the instrument URL (the quote post).
*
* @param string $activity_type The activity type (e.g., 'QuoteRequest').
* @param string $object_id The object identifier to search for.
*
* @return \WP_Post|\WP_Error The inbox item or WP_Error if not found.
Comment on lines +383 to +393
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

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

The documentation could be more specific about what "object_id" represents for different activity types. The current documentation only mentions QuoteRequest, but doesn't clarify what object_id means for other activities. Consider adding:

 * For QuoteRequest activities, the object_id is the instrument URL (the quote post).
 * For other activities, the object_id is the URL of the activity's object.

This would make it clearer to future developers how this method should be used.

Copilot uses AI. Check for mistakes.
*/
public static function get_by_type_and_object( $activity_type, $object_id ) {
global $wpdb;

// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$post_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT p.ID
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm1 ON p.ID = pm1.post_id AND pm1.meta_key = '_activitypub_activity_type'
INNER JOIN {$wpdb->postmeta} pm2 ON p.ID = pm2.post_id AND pm2.meta_key = '_activitypub_object_id'
WHERE p.post_type = %s
AND pm1.meta_value = %s
AND pm2.meta_value = %s
ORDER BY p.ID DESC
LIMIT 1",
self::POST_TYPE,
$activity_type,
$object_id
)
);
Comment on lines +399 to +414
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

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

Performance concern: the query uses two INNER JOINs on postmeta without ensuring indexes exist. WordPress postmeta table has indexes on meta_key but not composite indexes. This query could be slow on sites with large postmeta tables. Consider:

  1. Adding a note about potential performance implications
  2. Suggesting database indexes if this becomes a frequently called method
  3. Exploring alternative approaches like caching or using a more efficient query structure if performance becomes an issue

The query pattern is:

INNER JOIN {$wpdb->postmeta} pm1 ON p.ID = pm1.post_id AND pm1.meta_key = '_activitypub_activity_type'
INNER JOIN {$wpdb->postmeta} pm2 ON p.ID = pm2.post_id AND pm2.meta_key = '_activitypub_object_id'

This results in two table scans on postmeta which could be expensive.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

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

Could this be a WP_Query call instead? It might help with caching


if ( ! $post_id ) {
return new \WP_Error(
'activitypub_inbox_item_not_found',
\__( 'Inbox item not found', 'activitypub' ),
array( 'status' => 404 )
);
}

return \get_post( $post_id );
}

/**
* Deduplicate inbox items with the same GUID.
*
Expand Down
81 changes: 81 additions & 0 deletions includes/handler/class-quote-request.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Activitypub\Activity\Activity;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Inbox;
use Activitypub\Collection\Remote_Actors;

use function Activitypub\add_to_outbox;
Expand All @@ -27,6 +28,7 @@ class Quote_Request {
public static function init() {
\add_action( 'activitypub_inbox_quote_request', array( self::class, 'handle_quote_request' ), 10, 2 );
\add_action( 'activitypub_rest_inbox_disallowed', array( self::class, 'handle_blocked_request' ), 10, 3 );
\add_action( 'delete_comment', array( self::class, 'handle_quote_delete' ), 10, 2 );

\add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 );
}
Expand Down Expand Up @@ -100,6 +102,85 @@ public static function handle_blocked_request( $activity, $user_ids, $type ) {
self::queue_reject( $activity, $user_id );
}

/**
* Handle deletion of a quote comment.
*
* When a local quote comment is deleted, send a Reject activity to revoke
* the previously accepted QuoteRequest.
*
* @param int $comment_id The comment ID being deleted.
* @param \WP_Comment $comment The comment object.
*/
public static function handle_quote_delete( $comment_id, $comment ) {
// Only handle quote comments.
if ( 'quote' !== $comment->comment_type ) {
return;
}

// Get the post being quoted.
$post_id = $comment->comment_post_ID;
if ( ! $post_id ) {
return;
}

// Get the instrument URL (the quote post URL) from comment meta.
$instrument_url = \get_comment_meta( $comment_id, 'source_url', true );
if ( ! $instrument_url ) {
$instrument_url = \get_comment_meta( $comment_id, 'source_id', true );
}

if ( ! $instrument_url ) {
return;
}

// Get the post author (who accepted the quote).
$post = \get_post( $post_id );
if ( ! $post || ! $post->post_author ) {
return;
}
Comment on lines +138 to +140
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

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

Incomplete validation: the check ! $post->post_author will pass for posts with post_author = "0" (string zero) which is truthy in PHP, but will fail for legitimate posts with author ID 0. However, more importantly, this doesn't validate that the post author is a valid ActivityPub-enabled user. Consider using Actors::get_by_id() to validate the user exists and is an ActivityPub actor before proceeding, similar to what's done in queue_reject() at line 258.

Suggested change
if ( ! $post || ! $post->post_author ) {
return;
}
if ( ! $post || ! isset( $post->post_author ) ) {
return;
}
$actor = Actors::get_by_id( $post->post_author );
if ( ! $actor ) {
return;
}

Copilot uses AI. Check for mistakes.

/*
* Try to retrieve the original QuoteRequest from the inbox.
* For QuoteRequest activities, the inbox stores the instrument URL
* in _activitypub_object_id, so we can query by that.
*/
$activity_object = null;
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

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

The assignment $activity_object = null; is unnecessary since it's immediately used in a condition at line 150 that checks for null/empty. This line can be removed as $inbox_item result will be checked, and $activity_object is only assigned if the condition passes. If the condition fails, the fallback block at line 155 assigns a value to $activity_object, so the initialization to null serves no purpose.

Suggested change
$activity_object = null;

Copilot uses AI. Check for mistakes.
$inbox_item = Inbox::get_by_type_and_object( 'QuoteRequest', $instrument_url );

if ( ! \is_wp_error( $inbox_item ) && $inbox_item instanceof \WP_Post ) {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if ( ! \is_wp_error( $inbox_item ) && $inbox_item instanceof \WP_Post ) {
if ( $inbox_item instanceof \WP_Post ) {

$activity_object = \json_decode( $inbox_item->post_content, true );
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

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

Missing error handling for JSON decode failure. If $inbox_item->post_content contains invalid JSON, json_decode() will return null, causing the fallback path to be used silently. Consider checking for JSON decode errors explicitly:

$activity_object = \json_decode( $inbox_item->post_content, true );
if ( JSON_ERROR_NONE !== \json_last_error() ) {
    $activity_object = null;
}

This makes it clear when the fallback is used due to corrupted data versus a missing inbox item.

Suggested change
$activity_object = \json_decode( $inbox_item->post_content, true );
$activity_object = \json_decode( $inbox_item->post_content, true );
if ( JSON_ERROR_NONE !== \json_last_error() ) {
$activity_object = null;
}

Copilot uses AI. Check for mistakes.
}

// Fallback: If inbox item not found, reconstruct from available data.
if ( ! $activity_object ) {
// Log when fallback is used for monitoring/debugging.
\do_action( 'activitypub_quote_request_inbox_not_found', $instrument_url, $post_id );
Copy link
Member

Choose a reason for hiding this comment

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

Needs action docs. Do we even need this action?


$activity_object = array(
'type' => 'QuoteRequest',
'actor' => $comment->comment_author_url,
'object' => \get_permalink( $post_id ),
'instrument' => $instrument_url,
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

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

Consider including 'published' field in the fallback reconstruction for consistency with the test expectations. The test test_delete_uses_inbox_item creates a QuoteRequest with a 'published' field (line 646), and the comment at line 714 states "verify the reject object is a QuoteRequest with proper structure". While not required for the Reject to function, including all original fields in the fallback would maintain better parity with the retrieved inbox version and make the fallback more complete.

Suggested change
'instrument' => $instrument_url,
'instrument' => $instrument_url,
'published' => gmdate( 'c' ),

Copilot uses AI. Check for mistakes.
);
}

// Remove from _activitypub_quoted_by meta.
\delete_post_meta( $post_id, '_activitypub_quoted_by', $instrument_url );

// Send Reject activity to revoke the quote permission.
self::queue_reject( $activity_object, $post->post_author );

/**
* Fires after a quote comment has been deleted and Reject activity sent.
*
* @param int $comment_id The deleted comment ID.
* @param int $post_id The post ID that was quoted.
* @param string $instrument_url The instrument URL (quote post).
* @param array $activity_object The QuoteRequest activity that was rejected.
*/
\do_action( 'activitypub_quote_comment_deleted', $comment_id, $post_id, $instrument_url, $activity_object );
}

/**
* Send an Accept activity in response to the QuoteRequest.
*
Expand Down
Loading