Skip to content
Merged
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
fbbdcc2
Merge branch 'trunk' into store-quotes
pfefferle Nov 13, 2025
66e98e2
Merge branch 'trunk' into store-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 @@ -610,7 +610,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 @@ -629,7 +629,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 @@ -642,6 +642,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
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
3 changes: 2 additions & 1 deletion integration/class-jetpack.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,9 @@ public static function add_sync_comment_meta( $allow_list ) {
* @return array The comment types with ActivityPub types added.
*/
public static function add_comment_types( $comment_types ) {
$comment_types[] = 'repost';
$comment_types[] = 'like';
$comment_types[] = 'quote';
$comment_types[] = 'repost';

return array_unique( $comment_types );
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
* @package Activitypub
*/

namespace Activitypub\Activity;
namespace Activitypub\Tests\Activity;

use Activitypub\Activity\Base_Object;
use Activitypub\Activity\Generic_Object;
use WP_UnitTestCase;

/**
Expand Down Expand Up @@ -117,4 +119,79 @@ public function test_to_array() {
$this->assertEquals( $test_data['inReplyTo'], $array['inReplyTo'] );
$this->assertEquals( $test_data['mediaType'], $array['mediaType'] );
}

/**
* Test if init_from_array correctly handles quote property.
*
* Tests that the quote property can be set from array.
* Uses Base_Object which has the quote property defined.
*
* @covers Activitypub\Activity\Generic_Object::init_from_array
*/
public function test_init_from_array_quote_property() {
$test_data = array(
'id' => 'https://example.com/note/123',
'type' => 'Note',
'quote' => 'https://example.com/post/456',
);

$object = Base_Object::init_from_array( $test_data );

// Verify quote property is accessible.
$this->assertEquals( $test_data['quote'], $object->get_quote() );
}

/**
* Test if init_from_array correctly handles underscore-prefixed properties.
*
* Uses Base_Object which has the _misskey_quote property defined.
*
* @covers Activitypub\Activity\Generic_Object::init_from_array
*/
public function test_init_from_array_underscore_properties() {
$test_data = array(
'id' => 'https://example.com/note/123',
'type' => 'Note',
'_misskey_quote' => 'https://example.com/post/789',
);

$object = Base_Object::init_from_array( $test_data );

// Test that underscore property is accessible.
$this->assertEquals( $test_data['_misskey_quote'], $object->get__misskey_quote() );
}

/**
* Test quote properties round-trip through set/get.
*
* Uses Base_Object to verify quote properties can be set and retrieved.
*
* @covers Activitypub\Activity\Generic_Object::__call
*/
public function test_quote_properties_set_and_get() {
$object = new Base_Object();

$object->set_quote( 'https://example.com/post/456' );
$object->set_quote_url( 'https://example.com/post/789' );
$object->set_quote_uri( 'https://example.com/post/101' );

$this->assertEquals( 'https://example.com/post/456', $object->get_quote() );
$this->assertEquals( 'https://example.com/post/789', $object->get_quote_url() );
$this->assertEquals( 'https://example.com/post/101', $object->get_quote_uri() );
}

/**
* Test underscore-prefixed properties round-trip through set/get.
*
* Uses Base_Object to verify _misskey_quote property can be set and retrieved.
*
* @covers Activitypub\Activity\Generic_Object::__call
*/
public function test_underscore_properties_set_and_get() {
$object = new Base_Object();

$object->set__misskey_quote( 'https://example.com/post/789' );

$this->assertEquals( 'https://example.com/post/789', $object->get__misskey_quote() );
}
}
Loading