Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3afe849
Add support for quote comments and improve quote detection
pfefferle Oct 17, 2025
a6b77d4
Merge branch 'trunk' into store-quotes
pfefferle Oct 17, 2025
4b3b44d
Update includes/functions.php
pfefferle Oct 17, 2025
f3a587c
Update includes/collection/class-interactions.php
pfefferle Oct 17, 2025
2a8353b
Refactor comment addition validation logic
pfefferle Oct 17, 2025
960dfe1
Add facepile display style for comment types
pfefferle Oct 17, 2025
f3e6cf9
Merge branch 'trunk' into store-quotes
pfefferle Oct 20, 2025
5553a2a
Merge branch 'trunk' into store-quotes
pfefferle Oct 20, 2025
d01494f
Merge branch 'trunk' into store-quotes
pfefferle Oct 22, 2025
22be295
Merge branch 'trunk' into store-quotes
pfefferle Nov 5, 2025
c011031
Remove facepile display logic from comment types
pfefferle Nov 5, 2025
3893740
Add tests for esc_hashtag function and filter
pfefferle Nov 5, 2025
ad6d41a
Add changelog
matticbot Nov 5, 2025
007d4db
Update includes/functions.php
pfefferle Nov 8, 2025
33839fe
Add support for quote activities in Fediverse
pfefferle Nov 10, 2025
3ff06ac
Merge branch 'trunk' into store-quotes
pfefferle Nov 10, 2025
a1ebc93
Skip camelCase conversion for keys prefixed with '_'
pfefferle Nov 10, 2025
79dcadd
Update includes/activity/class-base-object.php
pfefferle Nov 10, 2025
f96fe1c
Update includes/activity/class-base-object.php
pfefferle Nov 10, 2025
2eb2486
Update tests/phpunit/tests/includes/class-test-functions.php
pfefferle Nov 10, 2025
cfa6544
Add quote-related properties to Base_Object
pfefferle Nov 10, 2025
5d4187f
Remove translation from activity descriptions
pfefferle Nov 10, 2025
2e58dbe
Merge branch 'trunk' into store-quotes
pfefferle Nov 10, 2025
9b20c10
Fix camelCase to snake_case conversion logic
pfefferle Nov 10, 2025
7d4d27a
Rename is_activity_quote to is_quote_activity
pfefferle Nov 11, 2025
818ada9
Merge branch 'trunk' into store-quotes
pfefferle Nov 11, 2025
34c58ed
Fix snake_case conversion logic in Generic_Object
pfefferle Nov 11, 2025
adc9bc1
Update @covers annotations with full namespace
pfefferle Nov 11, 2025
b646fcb
Merge branch 'trunk' into store-quotes
pfefferle Nov 11, 2025
d4b193c
Merge branch 'trunk' into store-quotes
pfefferle Nov 11, 2025
e8b42e8
Add 'quote' to Jetpack comment types
pfefferle Nov 11, 2025
62be0dc
Merge branch 'trunk' into store-quotes
pfefferle Nov 13, 2025
fbbdcc2
Merge branch 'trunk' into store-quotes
pfefferle Nov 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions includes/class-comment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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' ),
)
);
}

/**
Expand Down
36 changes: 35 additions & 1 deletion includes/collection/class-interactions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] );
Expand Down Expand Up @@ -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: <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 );
}
}

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

return false;
}

/**
Expand Down
104 changes: 103 additions & 1 deletion 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 @@ -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' => '<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 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' => '<P CLASS="QUOTE-INLINE">re: <A HREF="https://example.com/post">Post</A></P>',
),
);

$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' => '<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 ) );
}
}
111 changes: 111 additions & 0 deletions tests/phpunit/tests/includes/collection/class-test-interactions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => '<p class="quote-inline">RE: <a href="https://example.com/posts/123">Example Post</a></p><p>My comment</p>',
),
);

$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' => '<p>Just a regular post</p>',
),
);

$result = Interactions::extract_quote_link( $activity );

$this->assertArrayNotHasKey( 'inReplyTo', $result['object'] );
$this->assertEquals( '<p>Just a regular post</p>', $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' => '<P CLASS="quote-inline">re: <A HREF="https://example.com/post">Post</A></P>',
),
);

$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' => '<p class="quote-inline">RE: <a href="' . self::$post_permalink . '">Post</a></p><p>Great post!</p>',
),
);

\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;
}
}