diff --git a/includes/class-comment.php b/includes/class-comment.php index ff90f6b39..e2c794324 100644 --- a/includes/class-comment.php +++ b/includes/class-comment.php @@ -636,11 +636,12 @@ 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', 'collection' => 'reposts', + 'display_style' => 'facepile', 'activity_types' => array( 'announce' ), 'excerpt' => html_entity_decode( \__( '… reposted this!', 'activitypub' ) ), /* translators: %d: Number of reposts */ @@ -655,11 +656,12 @@ 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', 'collection' => 'likes', + 'display_style' => 'facepile', 'activity_types' => array( 'like' ), 'excerpt' => html_entity_decode( \__( '… liked this!', 'activitypub' ) ), /* translators: %d: Number of likes */ @@ -668,6 +670,26 @@ 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', + 'display_style' => 'comment', + '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' ), + ) + ); } /** @@ -815,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; + } } diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 3d014dbe6..390599c95 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -34,10 +34,19 @@ 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'; + } else { + 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 ); @@ -376,4 +385,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, 1 ); + } + } + + return $activity; + } } diff --git a/includes/functions.php b/includes/functions.php index f2672ce9f..d5c0b8c1e 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -618,7 +618,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 7fd00663a..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. @@ -1408,121 +1407,105 @@ public function test_camel_to_snake_case( $original, $expected ) { } /** - * Data provider for esc_hashtag tests. + * Test is_activity_reply function with inReplyTo. * - * @return array Test cases with input and expected output. + * @covers \Activitypub\is_activity_reply */ - 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( 'ทดสอบ', '#ทดสอบ' ), + 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 esc_hashtag function. + * Test is_activity_reply function with quote-inline pattern. * - * @dataProvider esc_hashtag_provider - * @covers \Activitypub\esc_hashtag - * - * @param string $input The input string. - * @param string $expected The expected hashtag output. + * @covers \Activitypub\is_activity_reply */ - public function test_esc_hashtag( $input, $expected ) { - $result = \Activitypub\esc_hashtag( $input ); - $this->assertSame( $expected, $result ); + 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 esc_hashtag filter hook. + * Test is_activity_reply function with quote-inline (case insensitive). * - * @covers \Activitypub\esc_hashtag + * @covers \Activitypub\is_activity_reply */ - public function test_esc_hashtag_filter() { - $filter_callback = function ( $hashtag, $input ) { - if ( 'custom' === $input ) { - return '#CustomTag'; - } - return $hashtag; - }; + public function test_is_activity_reply_with_quote_inline_case_insensitive() { + $activity = array( + 'type' => 'Create', + 'object' => array( + 'type' => 'Note', + 'content' => 're: Post
', + ), + ); - \add_filter( 'activitypub_esc_hashtag', $filter_callback, 10, 2 ); + $this->assertTrue( \Activitypub\is_activity_reply( $activity ) ); + } - $result = \Activitypub\esc_hashtag( 'custom' ); - $this->assertSame( '#CustomTag', $result ); + /** + * 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', + ), + ); - \remove_filter( 'activitypub_esc_hashtag', $filter_callback, 10 ); + $this->assertFalse( \Activitypub\is_activity_reply( $activity ) ); } /** - * Test esc_hashtag with HTML special characters. + * Test is_activity_reply returns false when content is missing. * - * @covers \Activitypub\esc_hashtag + * @covers \Activitypub\is_activity_reply */ - public function test_esc_hashtag_html_escaping() { - $result = \Activitypub\esc_hashtag( '' ); - $this->assertStringNotContainsString( '