diff --git a/.github/changelog/2330-from-description b/.github/changelog/2330-from-description new file mode 100644 index 0000000000..13409ccc32 --- /dev/null +++ b/.github/changelog/2330-from-description @@ -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. diff --git a/includes/activity/class-base-object.php b/includes/activity/class-base-object.php index 87195d4390..0e7fe4ec92 100644 --- a/includes/activity/class-base-object.php +++ b/includes/activity/class-base-object.php @@ -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. @@ -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. @@ -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. * diff --git a/includes/activity/class-generic-object.php b/includes/activity/class-generic-object.php index 0da79d3bc7..29e6db63e5 100644 --- a/includes/activity/class-generic-object.php +++ b/includes/activity/class-generic-object.php @@ -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 ); } } diff --git a/includes/class-comment.php b/includes/class-comment.php index b42a6246df..ec13301ea2 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -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', @@ -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', @@ -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' ), + ) + ); } /** diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index efa640cebf..c6427d29f7 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -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 = '/
]*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; } @@ -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; + } } diff --git a/includes/functions.php b/includes/functions.php index 34db3b0f08..1bfcc9b881 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -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. * diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php index d271ef5101..7e841f27d7 100644 --- a/includes/handler/class-create.php +++ b/includes/handler/class-create.php @@ -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; @@ -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 ); diff --git a/integration/class-jetpack.php b/integration/class-jetpack.php index 250e6ad664..06eb03033b 100644 --- a/integration/class-jetpack.php +++ b/integration/class-jetpack.php @@ -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 ); } diff --git a/tests/phpunit/tests/includes/activity/class-test-generic-object.php b/tests/phpunit/tests/includes/activity/class-test-generic-object.php index 6ba948d1d2..ffddf556e4 100644 --- a/tests/phpunit/tests/includes/activity/class-test-generic-object.php +++ b/tests/phpunit/tests/includes/activity/class-test-generic-object.php @@ -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; /** @@ -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() ); + } } diff --git a/tests/phpunit/tests/includes/class-test-functions.php b/tests/phpunit/tests/includes/class-test-functions.php index 072a0999c5..3e1a5e11dc 100644 --- a/tests/phpunit/tests/includes/class-test-functions.php +++ b/tests/phpunit/tests/includes/class-test-functions.php @@ -1525,4 +1525,128 @@ public function test_esc_hashtag_with_quotes() { $result = \Activitypub\esc_hashtag( 'test's tag' ); $this->assertSame( '#testSTag', $result ); } + + /** + * Test is_activity_reply function with inReplyTo. + * + * @covers \Activitypub\is_activity_reply + */ + public function test_is_activity_reply_with_in_reply_to() { + $activity = array( + 'type' => 'Create', + 'object' => array( + 'type' => 'Note', + 'content' => 'This is a reply', + 'inReplyTo' => 'https://example.com/post/123', + ), + ); + + $this->assertTrue( \Activitypub\is_activity_reply( $activity ) ); + } + + /** + * Test is_activity_reply returns false for non-reply. + * + * @covers \Activitypub\is_activity_reply + */ + public function test_is_activity_reply_returns_false_for_non_reply() { + $activity = array( + 'type' => 'Create', + 'object' => array( + 'type' => 'Note', + 'content' => 'Just a regular post', + ), + ); + + $this->assertFalse( \Activitypub\is_activity_reply( $activity ) ); + } + + /** + * Test is_quote_activity function with quote property. + * + * @covers \Activitypub\is_quote_activity + */ + public function test_is_quote_activity_with_quote() { + $activity = array( + 'type' => 'Create', + 'object' => array( + 'type' => 'Note', + 'content' => '
RE: Post
My comment
', + 'quote' => 'https://example.com/post', + ), + ); + + $this->assertTrue( \Activitypub\is_quote_activity( $activity ) ); + } + + /** + * Test is_quote_activity function with quoteUrl property. + * + * @covers \Activitypub\is_quote_activity + */ + public function test_is_quote_activity_with_quote_url() { + $activity = array( + 'type' => 'Create', + 'object' => array( + 'type' => 'Note', + 'content' => 'My comment
', + 'quoteUrl' => 'https://example.com/post', + ), + ); + + $this->assertTrue( \Activitypub\is_quote_activity( $activity ) ); + } + + /** + * Test is_quote_activity function with quoteUri property. + * + * @covers \Activitypub\is_quote_activity + */ + public function test_is_quote_activity_with_quote_uri() { + $activity = array( + 'type' => 'Create', + 'object' => array( + 'type' => 'Note', + 'content' => 'My comment
', + 'quoteUri' => 'https://example.com/post', + ), + ); + + $this->assertTrue( \Activitypub\is_quote_activity( $activity ) ); + } + + /** + * Test is_quote_activity function with _misskey_quote property. + * + * @covers \Activitypub\is_quote_activity + */ + public function test_is_quote_activity_with_misskey_quote() { + $activity = array( + 'type' => 'Create', + 'object' => array( + 'type' => 'Note', + 'content' => 'My comment
', + '_misskey_quote' => 'https://example.com/post', + ), + ); + + $this->assertTrue( \Activitypub\is_quote_activity( $activity ) ); + } + + /** + * Test is_quote_activity returns false for non-quote. + * + * @covers \Activitypub\is_quote_activity + */ + public function test_is_quote_activity_returns_false_for_non_quote() { + $activity = array( + 'type' => 'Create', + 'object' => array( + 'type' => 'Note', + 'content' => 'Just a regular post', + ), + ); + + $this->assertFalse( \Activitypub\is_quote_activity( $activity ) ); + } } diff --git a/tests/phpunit/tests/includes/collection/class-test-interactions.php b/tests/phpunit/tests/includes/collection/class-test-interactions.php index 7b8c5fe9d9..8342043fac 100644 --- a/tests/phpunit/tests/includes/collection/class-test-interactions.php +++ b/tests/phpunit/tests/includes/collection/class-test-interactions.php @@ -884,4 +884,59 @@ public function actor_meta_data_comment_author( $response, $url ) { return $response; } + + /** + * Test add_comment with quote property. + * + * @covers ::add_comment + * @covers ::get_quote_url + */ + public function test_add_comment_with_quote_property() { + $activity = array( + 'type' => 'Create', + 'actor' => 'https://example.com/users/testuser', + 'object' => array( + 'type' => 'Note', + 'id' => 'https://example.com/note/456', + 'content' => 'RE: Post
Great post!
', + 'quote' => self::$post_permalink, + 'quoteUri' => self::$post_permalink, + ), + ); + + \add_filter( 'pre_get_remote_metadata_by_actor', array( $this, 'mock_actor_metadata' ), 10, 2 ); + + $comment_id = Interactions::add_comment( $activity ); + + $this->assertNotFalse( $comment_id ); + $this->assertIsInt( $comment_id ); + + $comment = \get_comment( $comment_id ); + $this->assertEquals( self::$post_id, $comment->comment_post_ID ); + $this->assertStringContainsString( 'Great post!', $comment->comment_content ); + $this->assertStringNotContainsString( 'quote-inline', $comment->comment_content ); + $this->assertEquals( 'quote', $comment->comment_type, 'Comment type should be set to quote' ); + + \remove_filter( 'pre_get_remote_metadata_by_actor', array( $this, 'mock_actor_metadata' ), 10 ); + } + + /** + * Mock actor metadata for testing. + * + * @param bool $response The value to return. + * @param string $url The actor URL. + * + * @return array Actor metadata. + */ + public function mock_actor_metadata( $response, $url ) { + if ( 'https://example.com/users/testuser' === $url ) { + return array( + 'name' => 'Test User', + 'preferredUsername' => 'testuser', + 'id' => 'https://example.com/users/testuser', + 'url' => 'https://example.com/@testuser', + ); + } + return $response; + } }