Skip to content
Draft
Show file tree
Hide file tree
Changes from 33 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
4 changes: 4 additions & 0 deletions .github/changelog/2330-from-description
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Added support for quote comments, improving detection and handling of quoted replies and links in post interactions.
45 changes: 45 additions & 0 deletions includes/activity/class-base-object.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
* @method string[]|null get_name_map() Gets the name map property of the object.
* @method string|null get_preview() Gets the entity that provides a preview of this object.
* @method string|null get_published() Gets the date and time the object was published in ISO 8601 format.
* @method string|null get_quote() Gets the quote property of the object (FEP-044f).
* @method string|null get_quote_url() Gets the quoteUrl property of the object.
* @method string|null get_quote_uri() Gets the quoteUri property of the object.
* @method string|null get__misskey_quote() Gets the _misskey_quote property of the object.
* @method string|array|null get_replies() Gets the collection of responses to this object.
* @method bool|null get_sensitive() Gets the sensitive property of the object.
* @method array|null get_shares() Gets the collection of shares for this object.
Expand Down Expand Up @@ -87,6 +91,10 @@
* @method Base_Object set_name_map( array|null $name_map ) Sets the name map property of the object.
* @method Base_Object set_preview( string $preview ) Sets the entity that provides a preview of this object.
* @method Base_Object set_published( string|null $published ) Sets the date and time the object was published in ISO 8601 format.
* @method Base_Object set_quote( string $quote ) Sets the quote property of the object (FEP-044f).
* @method Base_Object set_quote_url( string $quote_url ) Sets the quoteUrl property of the object.
* @method Base_Object set_quote_uri( string $quote_uri ) Sets the quoteUri property of the object.
* @method Base_Object set__misskey_quote( mixed $misskey_quote ) Sets the _misskey_quote property of the object.
* @method Base_Object set_replies( string|array $replies ) Sets the collection of responses to this object.
* @method Base_Object set_sensitive( bool|null $sensitive ) Sets the sensitive property of the object.
* @method Base_Object set_shares( array $shares ) Sets the collection of shares for this object.
Expand Down Expand Up @@ -552,6 +560,43 @@ class Base_Object extends Generic_Object {
*/
protected $interaction_policy;

/**
* Fediverse Enhancement Proposal 044f: Quote Property
*
* @see https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
* @see https://w3id.org/fep/044f#quote
*
* @var string|null
*/
protected $quote;

/**
* ActivityStreams quoteUrl property.
*
* @see https://www.w3.org/ns/activitystreams#quoteUrl
*
* @var string|null
*/
protected $quote_url;

/**
* Fedibird-specific quoteUri property.
*
* @see https://fedibird.com/ns#quoteUri
*
* @var string|null
*/
protected $quote_uri;

/**
* Misskey-specific quote property.
*
* @see https://misskey-hub.net/ns/#_misskey_quote
*
* @var string|null
*/
protected $_misskey_quote; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore

/**
* Generic getter.
*
Expand Down
6 changes: 5 additions & 1 deletion includes/activity/class-generic-object.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,11 @@ public static function init_from_array( $data ) {
public function from_array( $data ) {
foreach ( $data as $key => $value ) {
if ( null !== $value ) {
$key = camel_to_snake_case( $key );
// Convert camelCase to snake_case if not prefixed with '_'.
if ( ! \str_starts_with( $key, '_' ) ) {
$key = camel_to_snake_case( $key );
}

call_user_func( array( $this, 'set_' . $key ), $value );
}
}
Expand Down
23 changes: 21 additions & 2 deletions includes/class-comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ public static function register_comment_types() {
array(
'label' => __( 'Reposts', 'activitypub' ),
'singular' => __( 'Repost', 'activitypub' ),
'description' => __( 'A repost on the indieweb is a post that is purely a 100% re-publication of another (typically someone else\'s) post.', 'activitypub' ),
'description' => 'A repost (or Announce) is when a post appears in the timeline because someone else shared it, while still showing the original author as the source.',
'icon' => '♻️',
'class' => 'p-repost',
'type' => 'repost',
Expand All @@ -655,7 +655,7 @@ public static function register_comment_types() {
array(
'label' => __( 'Likes', 'activitypub' ),
'singular' => __( 'Like', 'activitypub' ),
'description' => __( 'A like is a popular webaction button and in some cases post type on various silos such as Facebook and Instagram.', 'activitypub' ),
'description' => 'A like is a small positive reaction that shows appreciation for a post without sharing it further.',
'icon' => '👍',
'class' => 'p-like',
'type' => 'like',
Expand All @@ -668,6 +668,25 @@ public static function register_comment_types() {
'count_plural' => _x( '%d likes', 'number of likes', 'activitypub' ),
)
);

register_comment_type(
'quote',
array(
'label' => __( 'Quotes', 'activitypub' ),
'singular' => __( 'Quote', 'activitypub' ),
'description' => 'A quote is when a post is shared along with an added comment, so the original post appears together with the sharer’s own words.',
'icon' => '❞',
'class' => 'p-quote',
'type' => 'quote',
'collection' => 'quotes',
'activity_types' => array( 'quote' ),
'excerpt' => html_entity_decode( \__( '… quoted this!', 'activitypub' ) ),
/* translators: %d: Number of quotes */
'count_single' => _x( '%d quote', 'number of quotes', 'activitypub' ),
/* translators: %d: Number of quotes */
'count_plural' => _x( '%d quotes', 'number of quotes', 'activitypub' ),
)
);
}

/**
Expand Down
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.


$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 @@ -371,6 +382,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
67 changes: 60 additions & 7 deletions includes/collection/class-interactions.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,42 @@ class Interactions {
public static function add_comment( $activity ) {
$comment_data = self::activity_to_comment( $activity );

if ( ! $comment_data || ! isset( $activity['object']['inReplyTo'] ) ) {
if ( ! $comment_data ) {
return false;
}

$in_reply_to = object_to_uri( $activity['object']['inReplyTo'] );
$in_reply_to = \esc_url_raw( $in_reply_to );
$comment_post_id = \url_to_postid( $in_reply_to );
$parent_comment_id = url_to_commentid( $in_reply_to );
// Determine target URL from reply or quote.
$parent_comment_id = 0;

if ( ! empty( $activity['object']['inReplyTo'] ) ) {
// Regular reply.
$target_url = object_to_uri( $activity['object']['inReplyTo'] );
$parent_comment_id = url_to_commentid( $target_url );
} else {
// Check for quote.
$target_url = self::get_quote_url( $activity );

// Save only replies and reactions.
if ( ! $target_url ) {
return false;
}

// Mark as quote and clean content.
$comment_data['comment_type'] = 'quote';

if ( ! empty( $activity['object']['content'] ) ) {
$pattern = '/<p[^>]*class=["\']quote-inline["\'][^>]*>.*?<\/p>/is';
$cleaned_content = \preg_replace( $pattern, '', $activity['object']['content'], 1 );
$comment_data['comment_content'] = \wp_kses_post( $cleaned_content );
}
}

// Get post ID from target URL.
$target_url = \esc_url_raw( $target_url );
$comment_post_id = \url_to_postid( $target_url );

// Handle nested replies (replies to comments).
if ( ! $comment_post_id && $parent_comment_id ) {
$parent_comment = get_comment( $parent_comment_id );
$parent_comment = \get_comment( $parent_comment_id );
$comment_post_id = $parent_comment->comment_post_ID;
}

Expand Down Expand Up @@ -411,4 +435,33 @@ public static function count_by_type( $post_id, $type ) {
)
);
}

/**
* Get the quote URL from an activity.
*
* Checks for quote properties in priority order: quote -> quoteUrl -> quoteUri -> _misskey_quote.
*
* @param array $activity The activity array.
*
* @return string|false The quote URL or false if not found.
*/
public static function get_quote_url( $activity ) {
if ( ! empty( $activity['object']['quote'] ) ) {
return object_to_uri( $activity['object']['quote'] );
}

if ( ! empty( $activity['object']['quoteUrl'] ) ) {
return object_to_uri( $activity['object']['quoteUrl'] );
}

if ( ! empty( $activity['object']['quoteUri'] ) ) {
return object_to_uri( $activity['object']['quoteUri'] );
}

if ( ! empty( $activity['object']['_misskey_quote'] ) ) {
return object_to_uri( $activity['object']['_misskey_quote'] );
}

return false;
}
}
16 changes: 16 additions & 0 deletions includes/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,22 @@ function is_activity_reply( $data ) {
return ! empty( $data['object']['inReplyTo'] );
}

/**
* Check if passed Activity is a quote.
*
* Checks for quote properties: quote, quoteUrl, quoteUri, or _misskey_quote.
*
* @param array $data The Activity object as array.
*
* @return boolean True if a quote, false if not.
*/
function is_quote_activity( $data ) {
return ! empty( $data['object']['quote'] ) ||
! empty( $data['object']['quoteUrl'] ) ||
! empty( $data['object']['quoteUri'] ) ||
! empty( $data['object']['_misskey_quote'] );
}

/**
* Get active users based on a given duration.
*
Expand Down
3 changes: 2 additions & 1 deletion includes/handler/class-create.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

use function Activitypub\get_activity_visibility;
use function Activitypub\is_activity_reply;
use function Activitypub\is_quote_activity;
use function Activitypub\is_self_ping;
use function Activitypub\object_id_to_comment;

Expand All @@ -38,7 +39,7 @@ public static function handle_create( $activity, $user_ids, $activity_object = n
// Check for private and/or direct messages.
if ( ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === get_activity_visibility( $activity ) ) {
$result = false;
} elseif ( is_activity_reply( $activity ) ) { // Check for replies.
} elseif ( is_activity_reply( $activity ) || is_quote_activity( $activity ) ) { // Check for replies and quotes.
$result = self::create_interaction( $activity, $user_ids, $activity_object );
} elseif ( \get_option( 'activitypub_create_posts', false ) ) { // Handle non-interaction objects.
$result = self::create_post( $activity, $user_ids, $activity_object );
Expand Down
Loading