From 3afe849fa7e111f717cf4a2657c58bb37d475a8d Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 17 Oct 2025 15:15:20 +0200 Subject: [PATCH 1/5] Add support for quote comments and improve quote detection Introduces a new 'quote' comment type, updates interaction handling to extract and process quote links from content, and enhances reply detection to recognize quote-inline patterns. Includes comprehensive tests for quote extraction and reply identification. --- includes/class-comment.php | 23 +++- includes/collection/class-interactions.php | 36 +++++- includes/functions.php | 15 ++- .../tests/includes/class-test-functions.php | 104 +++++++++++++++- .../collection/class-test-interactions.php | 111 ++++++++++++++++++ 5 files changed, 284 insertions(+), 5 deletions(-) diff --git a/includes/class-comment.php b/includes/class-comment.php index ff90f6b39..d0569d3b1 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -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.', 'activitypub' ), 'icon' => '♻️', 'class' => 'p-repost', 'type' => 'repost', @@ -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.', 'activitypub' ), 'icon' => '👍', 'class' => 'p-like', 'type' => 'like', @@ -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.', 'activitypub' ), + '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 f532ecf00..2445ad387 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -35,7 +35,12 @@ public static function add_comment( $activity ) { $comment_data = self::activity_to_comment( $activity ); if ( ! $comment_data || ! isset( $activity['object']['inReplyTo'] ) ) { - return false; + $activity = self::extract_quote_link( $activity ); + if ( ! empty( $activity['object']['inReplyTo'] ) ) { + $comment_data['comment_type'] = 'quote'; + } else { + return false; + } } $in_reply_to = object_to_uri( $activity['object']['inReplyTo'] ); @@ -346,4 +351,33 @@ public static function count_by_type( $post_id, $type ) { ) ); } + + /** + * Extract quote link from HTML content. + * + * Detects quote/reply links in the format used by Mastodon and other Fediverse platforms. + * Pattern:

RE: ...

. + * + * @param array $activity The activity array to search. + * + * @return array The extracted quote link or an empty array if not found. + */ + public static function extract_quote_link( $activity ) { + $content = $activity['object']['content'] ?? ''; + + // Pattern to match the entire quote-inline paragraph. + $full_pattern = '/]*class=["\']quote-inline["\'][^>]*>.*?<\/p>/is'; + + if ( \preg_match( $full_pattern, $content, $full_match ) ) { + // Extract the URL from the href attribute within the matched content. + $url_pattern = '/href=["\'](https?:\/\/[^"\']+)["\']/i'; + if ( \preg_match( $url_pattern, $full_match[0], $url_matches ) ) { + $activity['object']['inReplyTo'] = \esc_url_raw( $url_matches[1] ); + // Remove the entire quote-inline paragraph from content. + $activity['object']['content'] = \preg_replace( $full_pattern, '', $content ); + } + } + + return $activity; + } } diff --git a/includes/functions.php b/includes/functions.php index e46da9639..c5aa2d7b7 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -617,7 +617,20 @@ function is_activity_public( $data ) { * @return boolean True if a reply, false if not. */ function is_activity_reply( $data ) { - return ! empty( $data['object']['inReplyTo'] ); + if ( ! empty( $data['object']['inReplyTo'] ) ) { + return true; + } + + if ( empty( $data['object']['content'] ) ) { + return false; + } + + // very simple check for quote content. + if ( \preg_match( '/^

.*<\/p>/i', $data['object']['content'] ) ) { + return true; + } + + return false; } /** diff --git a/tests/phpunit/tests/includes/class-test-functions.php b/tests/phpunit/tests/includes/class-test-functions.php index 9374893dc..8fd9a2ac9 100644 --- a/tests/phpunit/tests/includes/class-test-functions.php +++ b/tests/phpunit/tests/includes/class-test-functions.php @@ -13,7 +13,6 @@ use function Activitypub\add_to_outbox; use function Activitypub\extract_recipients_from_activity; use function Activitypub\extract_recipients_from_activity_property; -use function Activitypub\get_activity_visibility; /** * Test class for Functions. @@ -1406,4 +1405,107 @@ public function camel_snake_case_provider() { public function test_camel_to_snake_case( $original, $expected ) { $this->assertSame( $expected, \Activitypub\camel_to_snake_case( $original ) ); } + + /** + * 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 function with quote-inline pattern. + * + * @covers \Activitypub\is_activity_reply + */ + public function test_is_activity_reply_with_quote_inline() { + $activity = array( + 'type' => 'Create', + 'object' => array( + 'type' => 'Note', + 'content' => '

RE: Post

My comment

', + ), + ); + + $this->assertTrue( \Activitypub\is_activity_reply( $activity ) ); + } + + /** + * Test is_activity_reply function with quote-inline (case insensitive). + * + * @covers \Activitypub\is_activity_reply + */ + public function test_is_activity_reply_with_quote_inline_case_insensitive() { + $activity = array( + 'type' => 'Create', + 'object' => array( + 'type' => 'Note', + 'content' => '

re: Post

', + ), + ); + + $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_activity_reply returns false when content is missing. + * + * @covers \Activitypub\is_activity_reply + */ + public function test_is_activity_reply_returns_false_without_content() { + $activity = array( + 'type' => 'Create', + 'object' => array( + 'type' => 'Note', + ), + ); + + $this->assertFalse( \Activitypub\is_activity_reply( $activity ) ); + } + + /** + * Test is_activity_reply with quote-inline not at start. + * + * @covers \Activitypub\is_activity_reply + */ + public function test_is_activity_reply_quote_inline_not_at_start() { + $activity = array( + 'type' => 'Create', + 'object' => array( + 'type' => 'Note', + 'content' => '

Some intro text

RE: Post

', + ), + ); + + // Should return false because quote-inline is not at the start. + $this->assertFalse( \Activitypub\is_activity_reply( $activity ) ); + } } diff --git a/tests/phpunit/tests/includes/collection/class-test-interactions.php b/tests/phpunit/tests/includes/collection/class-test-interactions.php index 34bc339ca..c1d92eb16 100644 --- a/tests/phpunit/tests/includes/collection/class-test-interactions.php +++ b/tests/phpunit/tests/includes/collection/class-test-interactions.php @@ -622,4 +622,115 @@ public function actor_meta_data_comment_author( $response, $url ) { return $response; } + + /** + * Test extract_quote_link method extracts quote from activity. + * + * @covers ::extract_quote_link + */ + public function test_extract_quote_link() { + $activity = array( + 'type' => 'Create', + 'object' => array( + 'type' => 'Note', + 'content' => '

RE: Example Post

My comment

', + ), + ); + + $result = Interactions::extract_quote_link( $activity ); + + $this->assertEquals( 'https://example.com/posts/123', $result['object']['inReplyTo'] ); + $this->assertStringNotContainsString( 'quote-inline', $result['object']['content'] ); + } + + /** + * Test extract_quote_link with no quote pattern. + * + * @covers ::extract_quote_link + */ + public function test_extract_quote_link_no_match() { + $activity = array( + 'type' => 'Create', + 'object' => array( + 'type' => 'Note', + 'content' => '

Just a regular post

', + ), + ); + + $result = Interactions::extract_quote_link( $activity ); + + $this->assertArrayNotHasKey( 'inReplyTo', $result['object'] ); + $this->assertEquals( '

Just a regular post

', $result['object']['content'] ); + } + + /** + * Test extract_quote_link with case insensitive pattern. + * + * @covers ::extract_quote_link + */ + public function test_extract_quote_link_case_insensitive() { + $activity = array( + 'type' => 'Create', + 'object' => array( + 'type' => 'Note', + 'content' => '

re: Post

', + ), + ); + + $result = Interactions::extract_quote_link( $activity ); + + $this->assertEquals( 'https://example.com/post', $result['object']['inReplyTo'] ); + } + + /** + * Test add_comment with quote-inline fallback. + * + * @covers ::add_comment + * @covers ::extract_quote_link + */ + public function test_add_comment_with_quote_link() { + $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!

', + ), + ); + + \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->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; + } } From 4b3b44def9e1daf2a6a50c614fa4a2b19f90b05a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 17 Oct 2025 15:17:58 +0200 Subject: [PATCH 2/5] Update includes/functions.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- includes/functions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/functions.php b/includes/functions.php index c5aa2d7b7..f059b0901 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -626,7 +626,7 @@ function is_activity_reply( $data ) { } // very simple check for quote content. - if ( \preg_match( '/^

.*<\/p>/i', $data['object']['content'] ) ) { + if ( \preg_match( '/^

.*?<\/p>/i', $data['object']['content'] ) ) { return true; } From f3a587cd0603c3b92ae653fcbaaa5a92c663f110 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 17 Oct 2025 15:18:10 +0200 Subject: [PATCH 3/5] Update includes/collection/class-interactions.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- includes/collection/class-interactions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 2445ad387..d92221ca4 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -374,7 +374,7 @@ public static function extract_quote_link( $activity ) { if ( \preg_match( $url_pattern, $full_match[0], $url_matches ) ) { $activity['object']['inReplyTo'] = \esc_url_raw( $url_matches[1] ); // Remove the entire quote-inline paragraph from content. - $activity['object']['content'] = \preg_replace( $full_pattern, '', $content ); + $activity['object']['content'] = \preg_replace( $full_pattern, '', $content, 1 ); } } From 2a8353bfa2b8e0bbab22ede1e78ba3ec6affa68f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 17 Oct 2025 15:20:15 +0200 Subject: [PATCH 4/5] Refactor comment addition validation logic Split the validation in add_comment() to separately check for valid comment data and the presence of 'inReplyTo'. This improves code clarity and ensures early return if comment data is invalid. --- includes/collection/class-interactions.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index d92221ca4..b890bf949 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -34,7 +34,11 @@ 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; + } + + if ( empty( $activity['object']['inReplyTo'] ) ) { $activity = self::extract_quote_link( $activity ); if ( ! empty( $activity['object']['inReplyTo'] ) ) { $comment_data['comment_type'] = 'quote'; From 960dfe144ea8a0169637e9736700f29fadc6668a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 17 Oct 2025 17:00:57 +0200 Subject: [PATCH 5/5] Add facepile display style for comment types Introduces a 'display_style' property to comment type definitions, distinguishing between 'facepile' and 'comment' display styles. Adds helper methods to check if a comment type should be shown as a facepile and to retrieve all facepile types, enabling more flexible comment rendering. --- includes/class-comment.php | 49 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/includes/class-comment.php b/includes/class-comment.php index d0569d3b1..e2c794324 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -641,6 +641,7 @@ public static function register_comment_types() { 'class' => 'p-repost', 'type' => 'repost', 'collection' => 'reposts', + 'display_style' => 'facepile', 'activity_types' => array( 'announce' ), 'excerpt' => html_entity_decode( \__( '… reposted this!', 'activitypub' ) ), /* translators: %d: Number of reposts */ @@ -660,6 +661,7 @@ public static function register_comment_types() { 'class' => 'p-like', 'type' => 'like', 'collection' => 'likes', + 'display_style' => 'facepile', 'activity_types' => array( 'like' ), 'excerpt' => html_entity_decode( \__( '… liked this!', 'activitypub' ) ), /* translators: %d: Number of likes */ @@ -679,6 +681,7 @@ public static function register_comment_types() { 'class' => 'p-quote', 'type' => 'quote', 'collection' => 'quotes', + 'display_style' => 'comment', 'activity_types' => array( 'quote' ), 'excerpt' => html_entity_decode( \__( '… quoted this!', 'activitypub' ) ), /* translators: %d: Number of quotes */ @@ -834,4 +837,50 @@ public static function pre_wp_update_comment_count_now( $new_count, $old_count, public static function is_comment_type_enabled( $comment_type ) { return '1' === get_option( "activitypub_allow_{$comment_type}s", '1' ); } + + /** + * Check if a comment type should be displayed as a facepile. + * + * Facepile display shows just avatars and counts (like likes and reposts), + * while comment display shows the full comment content (like quotes and regular comments). + * + * @param string $comment_type The comment type slug. + * @return bool True if the comment type should be displayed as a facepile. + */ + public static function is_facepile_type( $comment_type ) { + $type_data = self::get_comment_type( $comment_type ); + + if ( empty( $type_data ) ) { + return false; + } + + $display_style = $type_data['display_style'] ?? 'comment'; + + /** + * Filters whether a comment type should be displayed as a facepile. + * + * @param bool $is_facepile True if the comment type should be displayed as a facepile. + * @param string $comment_type The comment type slug. + * @param array $type_data The comment type data. + */ + return apply_filters( 'activitypub_is_facepile_type', 'facepile' === $display_style, $comment_type, $type_data ); + } + + /** + * Get comment types that should be displayed as facepile. + * + * @return array Array of comment type slugs that should be displayed as facepile. + */ + public static function get_facepile_types() { + $comment_types = self::get_comment_types(); + $facepile_types = array(); + + foreach ( $comment_types as $slug => $type_data ) { + if ( self::is_facepile_type( $slug ) ) { + $facepile_types[] = $slug; + } + } + + return $facepile_types; + } }