From 3afe849fa7e111f717cf4a2657c58bb37d475a8d Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 17 Oct 2025 15:15:20 +0200 Subject: [PATCH 01/22] 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 02/22] 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 03/22] 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 04/22] 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 05/22] 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; + } } From c011031598553fd0cd0e320702e438e436666803 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 5 Nov 2025 14:45:29 +0000 Subject: [PATCH 06/22] Remove facepile display logic from comment types Eliminated the 'display_style' property from comment type definitions and removed related methods for determining and retrieving facepile types. This simplifies comment type handling by no longer distinguishing between facepile and comment display styles. --- includes/class-comment.php | 49 -------------------------------------- 1 file changed, 49 deletions(-) diff --git a/includes/class-comment.php b/includes/class-comment.php index e2c794324..d0569d3b1 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -641,7 +641,6 @@ 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 */ @@ -661,7 +660,6 @@ 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 */ @@ -681,7 +679,6 @@ 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 */ @@ -837,50 +834,4 @@ 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; - } } From 389374009f093b719f737b9ffd5a2e1060ac8b78 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 5 Nov 2025 15:16:17 +0000 Subject: [PATCH 07/22] Add tests for esc_hashtag function and filter Introduces comprehensive PHPUnit tests for the esc_hashtag function, including a data provider with various input cases, filter hook behavior, HTML escaping, and handling of quoted strings. These tests ensure correct hashtag formatting, proper escaping, and extensibility via filters. --- .../tests/includes/class-test-functions.php | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/tests/phpunit/tests/includes/class-test-functions.php b/tests/phpunit/tests/includes/class-test-functions.php index 8fd9a2ac9..f3e07755e 100644 --- a/tests/phpunit/tests/includes/class-test-functions.php +++ b/tests/phpunit/tests/includes/class-test-functions.php @@ -1406,6 +1406,125 @@ public function test_camel_to_snake_case( $original, $expected ) { $this->assertSame( $expected, \Activitypub\camel_to_snake_case( $original ) ); } + /** + * Data provider for esc_hashtag tests. + * + * @return array Test cases with input and expected output. + */ + public function esc_hashtag_provider() { + return array( + 'simple_word' => array( 'test', '#test' ), + 'word_with_spaces' => array( 'test tag', '#testTag' ), + 'multiple_spaces' => array( 'test multiple spaces', '#testMultipleSpaces' ), + 'with_special_chars' => array( 'test@tag!', '#testTag' ), + 'with_underscores' => array( 'test_tag', '#testTag' ), + 'with_leading_hashtag' => array( '#test', '#Test' ), + 'with_multiple_hashtags' => array( '##test', '#Test' ), + 'with_leading_hyphen' => array( '-test', '#Test' ), + 'with_trailing_hyphen' => array( 'test-', '#test' ), + 'mixed_case' => array( 'TestTag', '#TestTag' ), + 'with_numbers' => array( 'test123', '#test123' ), + 'with_unicode' => array( 'tëst', '#tëst' ), + 'with_unicode_spaces' => array( 'tëst tàg', '#tëstTàg' ), + 'german_umlauts' => array( 'über straße', '#überStraße' ), + 'japanese_characters' => array( 'テスト', '#テスト' ), + 'arabic_characters' => array( 'اختبار', '#اختبار' ), + 'cyrillic_characters' => array( 'тест', '#тест' ), + 'empty_string' => array( '', '#' ), + 'only_spaces' => array( ' ', '#' ), + 'only_special_chars' => array( '@!#$%', '#' ), + 'hyphenated_words' => array( 'foo-bar-baz', '#fooBarBaz' ), + 'quotes' => array( "test'tag", '#testTag' ), + 'double_quotes' => array( 'test"tag', '#testTag' ), + 'ampersand' => array( 'test&tag', '#testTag' ), + 'html_entities' => array( 'test&tag', '#testTag' ), + 'leading_trailing_spaces' => array( ' test ', '#Test' ), + 'multiple_hyphens' => array( 'test--tag', '#testTag' ), + 'camelCase_preservation' => array( 'testTag', '#testTag' ), + 'with_dots' => array( 'test.tag', '#testTag' ), + 'with_commas' => array( 'test,tag', '#testTag' ), + 'with_semicolons' => array( 'test;tag', '#testTag' ), + 'with_slashes' => array( 'test/tag', '#testTag' ), + 'with_backslashes' => array( 'test\\tag', '#testTag' ), + 'with_parentheses' => array( 'test(tag)', '#testTag' ), + 'with_brackets' => array( 'test[tag]', '#testTag' ), + 'with_braces' => array( 'test{tag}', '#testTag' ), + 'emoji_mixed' => array( 'test 😀 tag', '#testTag' ), + 'chinese_characters' => array( '测试 标签', '#测试标签' ), + 'korean_characters' => array( '테스트 태그', '#테스트태그' ), + 'greek_characters' => array( 'δοκιμή', '#δοκιμή' ), + 'hebrew_characters' => array( 'בדיקה', '#בדיקה' ), + 'thai_characters' => array( 'ทดสอบ', '#ทดสอบ' ), + ); + } + + /** + * Test esc_hashtag function. + * + * @dataProvider esc_hashtag_provider + * @covers \Activitypub\esc_hashtag + * + * @param string $input The input string. + * @param string $expected The expected hashtag output. + */ + public function test_esc_hashtag( $input, $expected ) { + $result = \Activitypub\esc_hashtag( $input ); + $this->assertSame( $expected, $result ); + } + + /** + * Test esc_hashtag filter hook. + * + * @covers \Activitypub\esc_hashtag + */ + public function test_esc_hashtag_filter() { + $filter_callback = function ( $hashtag, $input ) { + if ( 'custom' === $input ) { + return '#CustomTag'; + } + return $hashtag; + }; + + \add_filter( 'activitypub_esc_hashtag', $filter_callback, 10, 2 ); + + $result = \Activitypub\esc_hashtag( 'custom' ); + $this->assertSame( '#CustomTag', $result ); + + \remove_filter( 'activitypub_esc_hashtag', $filter_callback, 10 ); + } + + /** + * Test esc_hashtag with HTML special characters. + * + * @covers \Activitypub\esc_hashtag + */ + public function test_esc_hashtag_html_escaping() { + $result = \Activitypub\esc_hashtag( '' ); + $this->assertStringNotContainsString( '