Skip to content
72 changes: 70 additions & 2 deletions includes/class-comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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 */
Expand All @@ -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' ),
)
);
}

/**
Expand Down Expand Up @@ -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;
}
}
40 changes: 39 additions & 1 deletion includes/collection/class-interactions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down Expand Up @@ -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: <p class="quote-inline">RE: <a href="...">...</a></p>.
*
* @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 = '/<p[^>]*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;
}
}
15 changes: 14 additions & 1 deletion includes/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 class="quote-inline">.*?<\/p>/i', $data['object']['content'] ) ) {
return true;
}

return false;
}

/**
Expand Down
165 changes: 74 additions & 91 deletions tests/phpunit/tests/includes/class-test-functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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&amp;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' => '<p class="quote-inline">RE: <a href="https://example.com/post">Post</a></p><p>My comment</p>',
),
);

$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' => '<P CLASS="QUOTE-INLINE">re: <A HREF="https://example.com/post">Post</A></P>',
),
);

\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( '<script>alert("xss")</script>' );
$this->assertStringNotContainsString( '<script>', $result );
$this->assertStringNotContainsString( 'alert', $result );
// The result should be HTML-escaped.
$this->assertStringStartsWith( '#', $result );
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 esc_hashtag with quoted strings.
* Test is_activity_reply with quote-inline not at start.
*
* @covers \Activitypub\esc_hashtag
* @covers \Activitypub\is_activity_reply
*/
public function test_esc_hashtag_with_quotes() {
// Test single quotes.
$result = \Activitypub\esc_hashtag( "test's tag" );
$this->assertSame( '#testSTag', $result );

// Test double quotes.
$result = \Activitypub\esc_hashtag( 'test"s tag' );
$this->assertSame( '#testSTag', $result );

// Test HTML entities for quotes.
$result = \Activitypub\esc_hashtag( 'test&#039;s tag' );
$this->assertSame( '#testSTag', $result );
public function test_is_activity_reply_quote_inline_not_at_start() {
$activity = array(
'type' => 'Create',
'object' => array(
'type' => 'Note',
'content' => '<p>Some intro text</p><p class="quote-inline">RE: <a href="https://example.com/post">Post</a></p>',
),
);

// Should return false because quote-inline is not at the start.
$this->assertFalse( \Activitypub\is_activity_reply( $activity ) );
}
}
Loading