Skip to content

Commit 7388765

Browse files
authored
Add support for quote reactions and improve quote detection (#2330)
1 parent 9467545 commit 7388765

File tree

11 files changed

+412
-13
lines changed

11 files changed

+412
-13
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: added
3+
4+
Added support for quote comments, improving detection and handling of quoted replies and links in post interactions.

includes/activity/class-base-object.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@
4646
* @method string[]|null get_name_map() Gets the name map property of the object.
4747
* @method string|null get_preview() Gets the entity that provides a preview of this object.
4848
* @method string|null get_published() Gets the date and time the object was published in ISO 8601 format.
49+
* @method string|null get_quote() Gets the quote property of the object (FEP-044f).
50+
* @method string|null get_quote_url() Gets the quoteUrl property of the object.
51+
* @method string|null get_quote_uri() Gets the quoteUri property of the object.
52+
* @method string|null get__misskey_quote() Gets the _misskey_quote property of the object.
4953
* @method string|array|null get_replies() Gets the collection of responses to this object.
5054
* @method bool|null get_sensitive() Gets the sensitive property of the object.
5155
* @method array|null get_shares() Gets the collection of shares for this object.
@@ -87,6 +91,10 @@
8791
* @method Base_Object set_name_map( array|null $name_map ) Sets the name map property of the object.
8892
* @method Base_Object set_preview( string $preview ) Sets the entity that provides a preview of this object.
8993
* @method Base_Object set_published( string|null $published ) Sets the date and time the object was published in ISO 8601 format.
94+
* @method Base_Object set_quote( string $quote ) Sets the quote property of the object (FEP-044f).
95+
* @method Base_Object set_quote_url( string $quote_url ) Sets the quoteUrl property of the object.
96+
* @method Base_Object set_quote_uri( string $quote_uri ) Sets the quoteUri property of the object.
97+
* @method Base_Object set__misskey_quote( mixed $misskey_quote ) Sets the _misskey_quote property of the object.
9098
* @method Base_Object set_replies( string|array $replies ) Sets the collection of responses to this object.
9199
* @method Base_Object set_sensitive( bool|null $sensitive ) Sets the sensitive property of the object.
92100
* @method Base_Object set_shares( array $shares ) Sets the collection of shares for this object.
@@ -552,6 +560,43 @@ class Base_Object extends Generic_Object {
552560
*/
553561
protected $interaction_policy;
554562

563+
/**
564+
* Fediverse Enhancement Proposal 044f: Quote Property
565+
*
566+
* @see https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
567+
* @see https://w3id.org/fep/044f#quote
568+
*
569+
* @var string|null
570+
*/
571+
protected $quote;
572+
573+
/**
574+
* ActivityStreams quoteUrl property.
575+
*
576+
* @see https://www.w3.org/ns/activitystreams#quoteUrl
577+
*
578+
* @var string|null
579+
*/
580+
protected $quote_url;
581+
582+
/**
583+
* Fedibird-specific quoteUri property.
584+
*
585+
* @see https://fedibird.com/ns#quoteUri
586+
*
587+
* @var string|null
588+
*/
589+
protected $quote_uri;
590+
591+
/**
592+
* Misskey-specific quote property.
593+
*
594+
* @see https://misskey-hub.net/ns/#_misskey_quote
595+
*
596+
* @var string|null
597+
*/
598+
protected $_misskey_quote; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
599+
555600
/**
556601
* Generic getter.
557602
*

includes/activity/class-generic-object.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,11 @@ public static function init_from_array( $data ) {
204204
public function from_array( $data ) {
205205
foreach ( $data as $key => $value ) {
206206
if ( null !== $value ) {
207-
$key = camel_to_snake_case( $key );
207+
// Convert camelCase to snake_case if not prefixed with '_'.
208+
if ( ! \str_starts_with( $key, '_' ) ) {
209+
$key = camel_to_snake_case( $key );
210+
}
211+
208212
call_user_func( array( $this, 'set_' . $key ), $value );
209213
}
210214
}

includes/class-comment.php

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,7 @@ public static function register_comment_types() {
610610
array(
611611
'label' => __( 'Reposts', 'activitypub' ),
612612
'singular' => __( 'Repost', 'activitypub' ),
613-
'description' => __( 'A repost on the indieweb is a post that is purely a 100% re-publication of another (typically someone else\'s) post.', 'activitypub' ),
613+
'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.',
614614
'icon' => '♻️',
615615
'class' => 'p-repost',
616616
'type' => 'repost',
@@ -629,7 +629,7 @@ public static function register_comment_types() {
629629
array(
630630
'label' => __( 'Likes', 'activitypub' ),
631631
'singular' => __( 'Like', 'activitypub' ),
632-
'description' => __( 'A like is a popular webaction button and in some cases post type on various silos such as Facebook and Instagram.', 'activitypub' ),
632+
'description' => 'A like is a small positive reaction that shows appreciation for a post without sharing it further.',
633633
'icon' => '👍',
634634
'class' => 'p-like',
635635
'type' => 'like',
@@ -642,6 +642,25 @@ public static function register_comment_types() {
642642
'count_plural' => _x( '%d likes', 'number of likes', 'activitypub' ),
643643
)
644644
);
645+
646+
register_comment_type(
647+
'quote',
648+
array(
649+
'label' => __( 'Quotes', 'activitypub' ),
650+
'singular' => __( 'Quote', 'activitypub' ),
651+
'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.',
652+
'icon' => '',
653+
'class' => 'p-quote',
654+
'type' => 'quote',
655+
'collection' => 'quotes',
656+
'activity_types' => array( 'quote' ),
657+
'excerpt' => html_entity_decode( \__( '… quoted this!', 'activitypub' ) ),
658+
/* translators: %d: Number of quotes */
659+
'count_single' => _x( '%d quote', 'number of quotes', 'activitypub' ),
660+
/* translators: %d: Number of quotes */
661+
'count_plural' => _x( '%d quotes', 'number of quotes', 'activitypub' ),
662+
)
663+
);
645664
}
646665

647666
/**

includes/collection/class-interactions.php

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,42 @@ class Interactions {
3434
public static function add_comment( $activity ) {
3535
$comment_data = self::activity_to_comment( $activity );
3636

37-
if ( ! $comment_data || ! isset( $activity['object']['inReplyTo'] ) ) {
37+
if ( ! $comment_data ) {
3838
return false;
3939
}
4040

41-
$in_reply_to = object_to_uri( $activity['object']['inReplyTo'] );
42-
$in_reply_to = \esc_url_raw( $in_reply_to );
43-
$comment_post_id = \url_to_postid( $in_reply_to );
44-
$parent_comment_id = url_to_commentid( $in_reply_to );
41+
// Determine target URL from reply or quote.
42+
$parent_comment_id = 0;
43+
44+
if ( ! empty( $activity['object']['inReplyTo'] ) ) {
45+
// Regular reply.
46+
$target_url = object_to_uri( $activity['object']['inReplyTo'] );
47+
$parent_comment_id = url_to_commentid( $target_url );
48+
} else {
49+
// Check for quote.
50+
$target_url = self::get_quote_url( $activity );
4551

46-
// Save only replies and reactions.
52+
if ( ! $target_url ) {
53+
return false;
54+
}
55+
56+
// Mark as quote and clean content.
57+
$comment_data['comment_type'] = 'quote';
58+
59+
if ( ! empty( $activity['object']['content'] ) ) {
60+
$pattern = '/<p[^>]*class=["\']quote-inline["\'][^>]*>.*?<\/p>/is';
61+
$cleaned_content = \preg_replace( $pattern, '', $activity['object']['content'], 1 );
62+
$comment_data['comment_content'] = \wp_kses_post( $cleaned_content );
63+
}
64+
}
65+
66+
// Get post ID from target URL.
67+
$target_url = \esc_url_raw( $target_url );
68+
$comment_post_id = \url_to_postid( $target_url );
69+
70+
// Handle nested replies (replies to comments).
4771
if ( ! $comment_post_id && $parent_comment_id ) {
48-
$parent_comment = get_comment( $parent_comment_id );
72+
$parent_comment = \get_comment( $parent_comment_id );
4973
$comment_post_id = $parent_comment->comment_post_ID;
5074
}
5175

@@ -411,4 +435,33 @@ public static function count_by_type( $post_id, $type ) {
411435
)
412436
);
413437
}
438+
439+
/**
440+
* Get the quote URL from an activity.
441+
*
442+
* Checks for quote properties in priority order: quote -> quoteUrl -> quoteUri -> _misskey_quote.
443+
*
444+
* @param array $activity The activity array.
445+
*
446+
* @return string|false The quote URL or false if not found.
447+
*/
448+
public static function get_quote_url( $activity ) {
449+
if ( ! empty( $activity['object']['quote'] ) ) {
450+
return object_to_uri( $activity['object']['quote'] );
451+
}
452+
453+
if ( ! empty( $activity['object']['quoteUrl'] ) ) {
454+
return object_to_uri( $activity['object']['quoteUrl'] );
455+
}
456+
457+
if ( ! empty( $activity['object']['quoteUri'] ) ) {
458+
return object_to_uri( $activity['object']['quoteUri'] );
459+
}
460+
461+
if ( ! empty( $activity['object']['_misskey_quote'] ) ) {
462+
return object_to_uri( $activity['object']['_misskey_quote'] );
463+
}
464+
465+
return false;
466+
}
414467
}

includes/functions.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,22 @@ function is_activity_reply( $data ) {
631631
return ! empty( $data['object']['inReplyTo'] );
632632
}
633633

634+
/**
635+
* Check if passed Activity is a quote.
636+
*
637+
* Checks for quote properties: quote, quoteUrl, quoteUri, or _misskey_quote.
638+
*
639+
* @param array $data The Activity object as array.
640+
*
641+
* @return boolean True if a quote, false if not.
642+
*/
643+
function is_quote_activity( $data ) {
644+
return ! empty( $data['object']['quote'] ) ||
645+
! empty( $data['object']['quoteUrl'] ) ||
646+
! empty( $data['object']['quoteUri'] ) ||
647+
! empty( $data['object']['_misskey_quote'] );
648+
}
649+
634650
/**
635651
* Get active users based on a given duration.
636652
*

includes/handler/class-create.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
use function Activitypub\get_activity_visibility;
1414
use function Activitypub\is_activity_reply;
15+
use function Activitypub\is_quote_activity;
1516
use function Activitypub\is_self_ping;
1617
use function Activitypub\object_id_to_comment;
1718

@@ -38,7 +39,7 @@ public static function handle_create( $activity, $user_ids, $activity_object = n
3839
// Check for private and/or direct messages.
3940
if ( ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === get_activity_visibility( $activity ) ) {
4041
$result = false;
41-
} elseif ( is_activity_reply( $activity ) ) { // Check for replies.
42+
} elseif ( is_activity_reply( $activity ) || is_quote_activity( $activity ) ) { // Check for replies and quotes.
4243
$result = self::create_interaction( $activity, $user_ids, $activity_object );
4344
} elseif ( \get_option( 'activitypub_create_posts', false ) ) { // Handle non-interaction objects.
4445
$result = self::create_post( $activity, $user_ids, $activity_object );

integration/class-jetpack.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,9 @@ public static function add_sync_comment_meta( $allow_list ) {
7777
* @return array The comment types with ActivityPub types added.
7878
*/
7979
public static function add_comment_types( $comment_types ) {
80-
$comment_types[] = 'repost';
8180
$comment_types[] = 'like';
81+
$comment_types[] = 'quote';
82+
$comment_types[] = 'repost';
8283

8384
return array_unique( $comment_types );
8485
}

tests/phpunit/tests/includes/activity/class-test-generic-object.php

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
* @package Activitypub
66
*/
77

8-
namespace Activitypub\Activity;
8+
namespace Activitypub\Tests\Activity;
99

10+
use Activitypub\Activity\Base_Object;
11+
use Activitypub\Activity\Generic_Object;
1012
use WP_UnitTestCase;
1113

1214
/**
@@ -117,4 +119,79 @@ public function test_to_array() {
117119
$this->assertEquals( $test_data['inReplyTo'], $array['inReplyTo'] );
118120
$this->assertEquals( $test_data['mediaType'], $array['mediaType'] );
119121
}
122+
123+
/**
124+
* Test if init_from_array correctly handles quote property.
125+
*
126+
* Tests that the quote property can be set from array.
127+
* Uses Base_Object which has the quote property defined.
128+
*
129+
* @covers Activitypub\Activity\Generic_Object::init_from_array
130+
*/
131+
public function test_init_from_array_quote_property() {
132+
$test_data = array(
133+
'id' => 'https://example.com/note/123',
134+
'type' => 'Note',
135+
'quote' => 'https://example.com/post/456',
136+
);
137+
138+
$object = Base_Object::init_from_array( $test_data );
139+
140+
// Verify quote property is accessible.
141+
$this->assertEquals( $test_data['quote'], $object->get_quote() );
142+
}
143+
144+
/**
145+
* Test if init_from_array correctly handles underscore-prefixed properties.
146+
*
147+
* Uses Base_Object which has the _misskey_quote property defined.
148+
*
149+
* @covers Activitypub\Activity\Generic_Object::init_from_array
150+
*/
151+
public function test_init_from_array_underscore_properties() {
152+
$test_data = array(
153+
'id' => 'https://example.com/note/123',
154+
'type' => 'Note',
155+
'_misskey_quote' => 'https://example.com/post/789',
156+
);
157+
158+
$object = Base_Object::init_from_array( $test_data );
159+
160+
// Test that underscore property is accessible.
161+
$this->assertEquals( $test_data['_misskey_quote'], $object->get__misskey_quote() );
162+
}
163+
164+
/**
165+
* Test quote properties round-trip through set/get.
166+
*
167+
* Uses Base_Object to verify quote properties can be set and retrieved.
168+
*
169+
* @covers Activitypub\Activity\Generic_Object::__call
170+
*/
171+
public function test_quote_properties_set_and_get() {
172+
$object = new Base_Object();
173+
174+
$object->set_quote( 'https://example.com/post/456' );
175+
$object->set_quote_url( 'https://example.com/post/789' );
176+
$object->set_quote_uri( 'https://example.com/post/101' );
177+
178+
$this->assertEquals( 'https://example.com/post/456', $object->get_quote() );
179+
$this->assertEquals( 'https://example.com/post/789', $object->get_quote_url() );
180+
$this->assertEquals( 'https://example.com/post/101', $object->get_quote_uri() );
181+
}
182+
183+
/**
184+
* Test underscore-prefixed properties round-trip through set/get.
185+
*
186+
* Uses Base_Object to verify _misskey_quote property can be set and retrieved.
187+
*
188+
* @covers Activitypub\Activity\Generic_Object::__call
189+
*/
190+
public function test_underscore_properties_set_and_get() {
191+
$object = new Base_Object();
192+
193+
$object->set__misskey_quote( 'https://example.com/post/789' );
194+
195+
$this->assertEquals( 'https://example.com/post/789', $object->get__misskey_quote() );
196+
}
120197
}

0 commit comments

Comments
 (0)